Compare commits

...
Author SHA1 Message Date
Paul Bottein 1717d07049 Use card editor style YAML toggle for sections 2026-09-07 15:47:23 +02:00
Paul Bottein 33a31ff345 Add UI support for section strategies 2026-09-07 15:37:38 +02:00
9 changed files with 381 additions and 34 deletions
@@ -1,5 +1,6 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import {
mdiAutoFix,
mdiDelete,
mdiDotsVertical,
mdiDragHorizontalVariant,
@@ -7,13 +8,14 @@ import {
mdiPlusCircleMultipleOutline,
} from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
@@ -32,10 +34,32 @@ export class HuiSectionEditMode extends LitElement {
@property({ attribute: false }) public viewIndex!: number;
@property({ type: Boolean, attribute: "is-strategy", reflect: true })
public isStrategy = false;
protected render(): TemplateResult {
return html`
<div class="section-header">
<div class="section-actions">
${
this.isStrategy
? html`
<ha-svg-icon
id="strategy-icon"
.path=${mdiAutoFix}
role="img"
aria-label=${this.hass.localize(
"ui.panel.lovelace.editor.section.automatic"
)}
></ha-svg-icon>
<ha-tooltip for="strategy-icon">
${this.hass.localize(
"ui.panel.lovelace.editor.section.automatic"
)}
</ha-tooltip>
`
: nothing
}
<ha-svg-icon
aria-hidden="true"
class="handle"
@@ -70,7 +94,25 @@ export class HuiSectionEditMode extends LitElement {
</div>
</div>
<div class="section-wrapper">
<slot></slot>
<div class="section-content" ?inert=${this.isStrategy}>
<slot></slot>
</div>
${
this.isStrategy
? html`
<button
class="edit-overlay"
type="button"
aria-label=${this.hass.localize(
"ui.panel.lovelace.editor.section.edit_automatic"
)}
@click=${this._editSection}
>
<ha-svg-icon .path=${mdiPencil}></ha-svg-icon>
</button>
`
: nothing
}
</div>
`;
}
@@ -183,7 +225,12 @@ export class HuiSectionEditMode extends LitElement {
padding: 8px;
}
#strategy-icon {
padding: var(--ha-space-2);
}
.section-wrapper {
position: relative;
padding: 8px;
border-radius: var(
--ha-section-border-radius,
@@ -193,6 +240,67 @@ export class HuiSectionEditMode extends LitElement {
border: 2px dashed var(--divider-color);
min-height: var(--row-height);
}
:host([is-strategy]) .section-wrapper {
border-style: solid;
}
.section-content {
position: relative;
z-index: 0;
}
.edit-overlay {
position: absolute;
z-index: 0;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: inherit;
background: none;
color: var(--primary-text-color);
cursor: pointer;
opacity: 0;
transition: opacity var(--ha-animation-duration-fast) ease-in-out;
}
.edit-overlay::before {
content: "";
position: absolute;
inset: 0;
border: 1px solid var(--divider-color);
border-radius: inherit;
background: var(--primary-background-color);
opacity: 0.8;
}
.edit-overlay:hover,
.edit-overlay:focus-visible,
.edit-overlay:active {
opacity: 1;
}
.edit-overlay:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: -2px;
}
.edit-overlay ha-svg-icon {
position: relative;
padding: var(--ha-space-2);
border-radius: var(--ha-border-radius-circle);
background: var(--secondary-background-color);
--mdc-icon-size: 20px;
}
@media (prefers-reduced-motion: reduce) {
.edit-overlay {
transition: none;
}
}
`,
];
}
@@ -1,15 +1,13 @@
import {
mdiClose,
mdiDotsVertical,
mdiFileMoveOutline,
mdiPlaylistEdit,
} from "@mdi/js";
import { mdiClose, mdiDotsVertical, mdiFileMoveOutline } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { cache } from "lit/directives/cache";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { withViewTransition } from "../../../../common/util/view-transition";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-dialog-footer";
@@ -22,6 +20,8 @@ import "../../../../components/ha-tab-group-tab";
import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
import { isStrategySection } from "../../../../data/lovelace/config/section";
import type { LovelaceStrategyConfig } from "../../../../data/lovelace/config/strategy";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import { saveConfig } from "../../../../data/lovelace/config/types";
import {
@@ -35,7 +35,8 @@ import {
haStyleDialog,
haStyleDialogFixedTop,
} from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import { cleanLegacyStrategyConfig } from "../../strategies/legacy-strategy";
import type { Lovelace } from "../../types";
import { addSection, deleteSection, moveSection } from "../config-util";
import {
@@ -45,12 +46,15 @@ import {
import { showSelectViewDialog } from "../select-view/show-select-view-dialog";
import "./hui-section-settings-editor";
import "./hui-section-visibility-editor";
import "./hui-section-strategy-element-editor";
import type { HuiSectionStrategyElementEditor } from "./hui-section-strategy-element-editor";
import type { ConfigChangedEvent } from "../hui-element-editor";
import type { EditSectionDialogParams } from "./show-edit-section-dialog";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import { getViewType } from "../../views/get-view-type";
import { SECTIONS_VIEW_LAYOUT } from "../../views/const";
const TABS = ["tab-settings", "tab-visibility"] as const;
const TABS = ["tab-strategy", "tab-appearance", "tab-visibility"] as const;
@customElement("hui-dialog-edit-section")
export class HuiDialogEditSection
@@ -73,8 +77,15 @@ export class HuiDialogEditSection
@state() private _open = false;
@state() private _strategyError = false;
@state() private _yamlError = false;
@query("ha-yaml-editor") private _editor?: HaYamlEditor;
@query("hui-section-strategy-element-editor")
private _strategyEditor?: HuiSectionStrategyElementEditor;
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (this._yamlMode && changedProperties.has("_yamlMode")) {
@@ -95,6 +106,9 @@ export class HuiDialogEditSection
this._params.viewIndex,
this._params.sectionIndex,
]);
this._currTab = isStrategySection(this._config)
? "tab-strategy"
: "tab-appearance";
this._viewConfig = findLovelaceContainer(this._params.lovelaceConfig, [
this._params.viewIndex,
]);
@@ -111,6 +125,8 @@ export class HuiDialogEditSection
this._yamlMode = false;
this._config = undefined;
this._currTab = TABS[0];
this._strategyError = false;
this._yamlError = false;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -123,6 +139,15 @@ export class HuiDialogEditSection
"ui.panel.lovelace.editor.edit_section.header"
);
const strategy = isStrategySection(this._config)
? cleanLegacyStrategyConfig(this._config.strategy)
: undefined;
const tabs = strategy ? TABS : TABS.filter((tab) => tab !== "tab-strategy");
const currentTab =
!strategy && this._currTab === "tab-strategy"
? "tab-appearance"
: this._currTab;
let content: TemplateResult<1> | typeof nothing = nothing;
if (this._yamlMode) {
@@ -134,8 +159,8 @@ export class HuiDialogEditSection
></ha-yaml-editor>
`;
} else {
switch (this._currTab) {
case "tab-settings":
switch (currentTab) {
case "tab-appearance":
content = html`
<hui-section-settings-editor
.config=${this._config}
@@ -155,6 +180,17 @@ export class HuiDialogEditSection
</hui-section-visibility-editor>
`;
break;
case "tab-strategy":
content = html`
<hui-section-strategy-element-editor
.hass=${this.hass}
.lovelace=${this._params.lovelaceConfig}
.value=${strategy}
in-dialog
@config-changed=${this._strategyConfigChanged}
></hui-section-strategy-element-editor>
`;
break;
}
}
@@ -187,12 +223,6 @@ export class HuiDialogEditSection
.label=${this.hass!.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item value="toggle-yaml">
<ha-svg-icon slot="icon" .path=${mdiPlaylistEdit}></ha-svg-icon>
${this.hass.localize(
`ui.panel.lovelace.editor.edit_view.edit_${!this._yamlMode ? "yaml" : "ui"}`
)}
</ha-dropdown-item>
<ha-dropdown-item value="move-to-view">
<ha-svg-icon
slot="icon"
@@ -207,12 +237,12 @@ export class HuiDialogEditSection
!this._yamlMode
? html`
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
${TABS.map(
${tabs.map(
(tab) => html`
<ha-tab-group-tab
slot="nav"
.panel=${tab}
.active=${this._currTab === tab}
.active=${currentTab === tab}
>
${this.hass!.localize(
`ui.panel.lovelace.editor.edit_section.${tab.replace("-", "_")}`
@@ -225,8 +255,21 @@ export class HuiDialogEditSection
: nothing
}
</ha-dialog-header>
${content}
${this._yamlMode ? content : cache(content)}
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
class="gui-mode-button"
appearance="plain"
@click=${this._toggleMode}
.disabled=${this._yamlMode && this._yamlError}
>
${this.hass.localize(
this._yamlMode
? "ui.panel.lovelace.editor.edit_card.show_visual_editor"
: "ui.panel.lovelace.editor.edit_card.show_code_editor"
)}
</ha-button>
<ha-button
slot="secondaryAction"
appearance="plain"
@@ -238,7 +281,7 @@ export class HuiDialogEditSection
<ha-button
slot="primaryAction"
@click=${this._save}
?disabled=${!this.isDirtyState}
?disabled=${!this.isDirtyState || this._yamlError || this._strategyError}
>
${this.hass!.localize("ui.common.save")}
</ha-button>
@@ -247,12 +290,30 @@ export class HuiDialogEditSection
`;
}
private _configChanged(ev: CustomEvent): void {
private _configChanged(
ev: ValueChangedEvent<LovelaceSectionRawConfig>
): void {
ev.stopPropagation();
this._config = ev.detail.value;
this._updateDirtyState(this._config!);
}
private _strategyConfigChanged(
ev: HASSDomEvent<ConfigChangedEvent<LovelaceStrategyConfig>>
): void {
ev.stopPropagation();
if (!this._config || !isStrategySection(this._config)) return;
this._strategyError = Boolean(
this._strategyEditor?.hasError ||
ev.detail.error ||
typeof ev.detail.config?.type !== "string" ||
!ev.detail.config.type
);
if (this._strategyError) return;
this._config = { ...this._config, strategy: ev.detail.config };
this._updateDirtyState(this._config);
}
private _handleTabChanged(ev: CustomEvent): void {
const newTab = ev.detail.name;
if (newTab === this._currTab) {
@@ -264,15 +325,19 @@ export class HuiDialogEditSection
private async _handleAction(ev: HaDropdownSelectEvent) {
const value = ev.detail.item.value;
switch (value) {
case "toggle-yaml":
this._yamlMode = !this._yamlMode;
break;
case "move-to-view":
this._openSelectView();
break;
}
}
private _toggleMode(): void {
if (this._yamlMode && this._yamlError) return;
withViewTransition(() => {
this._yamlMode = !this._yamlMode;
});
}
private _openSelectView(): void {
if (!this._params || !this.lovelace) {
return;
@@ -402,12 +467,21 @@ export class HuiDialogEditSection
}
};
private _viewYamlChanged(ev: CustomEvent) {
private _viewYamlChanged(
ev: ValueChangedEvent<LovelaceSectionRawConfig> &
HASSDomEvent<{ isValid: boolean }>
) {
ev.stopPropagation();
if (!ev.detail.isValid) {
this._yamlError =
!ev.detail.isValid ||
!ev.detail.value ||
typeof ev.detail.value !== "object" ||
Array.isArray(ev.detail.value);
if (this._yamlError) {
return;
}
this._config = ev.detail.value;
this._strategyError = false;
this._updateDirtyState(this._config!);
}
@@ -423,7 +497,12 @@ export class HuiDialogEditSection
}
private async _save(): Promise<void> {
if (!this._params || !this._config) {
if (
!this._params ||
!this._config ||
this._yamlError ||
this._strategyError
) {
return;
}
const newConfig = updateLovelaceContainer(
@@ -448,6 +527,9 @@ export class HuiDialogEditSection
ha-dialog.yaml-mode {
--dialog-content-padding: 0;
}
.gui-mode-button {
margin-inline-end: auto;
}
ha-tab-group-tab {
flex: 1;
}
@@ -0,0 +1,35 @@
import { css } from "lit";
import { customElement } from "lit/decorators";
import type { LovelaceStrategyConfig } from "../../../../data/lovelace/config/strategy";
import { getLovelaceStrategy } from "../../strategies/get-strategy";
import type { LovelaceStrategyEditor } from "../../strategies/types";
import { HuiTypedElementEditor } from "../hui-typed-element-editor";
@customElement("hui-section-strategy-element-editor")
export class HuiSectionStrategyElementEditor extends HuiTypedElementEditor<LovelaceStrategyConfig> {
static styles = [
HuiTypedElementEditor.styles,
css`
.gui-editor,
.yaml-editor {
padding: 0;
}
`,
];
protected async getConfigElement(): Promise<
LovelaceStrategyEditor | undefined
> {
const strategy = await getLovelaceStrategy(
"section",
this.configElementType!
);
return strategy.getConfigElement?.();
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-section-strategy-element-editor": HuiSectionStrategyElementEditor;
}
}
+2 -2
View File
@@ -324,7 +324,7 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
this._layoutElementType = config.type;
this._layoutElement.addEventListener("ll-create-card", (ev) => {
ev.stopPropagation();
if (!this.lovelace) return;
if (!this.lovelace || isStrategySection(this.config)) return;
showCreateCardDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
@@ -357,7 +357,7 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
});
this._layoutElement.addEventListener("ll-delete-card", (ev) => {
ev.stopPropagation();
if (!this.lovelace) return;
if (!this.lovelace || isStrategySection(this.config)) return;
performDeleteCard(this.hass, this.lovelace, ev.detail);
});
this._layoutElement.addEventListener("ll-duplicate-card", (ev) => {
+2 -1
View File
@@ -17,7 +17,8 @@ export interface LovelaceStrategy<T = any> {
newHass: HomeAssistant
): boolean;
registryDependencies?: readonly LovelaceStrategyDependency[];
getConfigElement?: () => LovelaceStrategyEditor;
getConfigElement?: () =>
LovelaceStrategyEditor | Promise<LovelaceStrategyEditor>;
noEditor?: boolean;
configRequired?: boolean;
}
@@ -7,7 +7,10 @@ import type { HomeAssistant } from "../../../../types";
import type { HeadingCardConfig } from "../../cards/types";
import type { Condition } from "../../common/validate-condition";
import { computeFavoriteCardConfig } from "../helpers/favorite-cards";
import type { LovelaceStrategyDependency } from "../types";
import type {
LovelaceStrategyDependency,
LovelaceStrategyEditor,
} from "../types";
const DEFAULT_LIMIT = 8;
@@ -30,6 +33,13 @@ export interface CommonControlsSectionStrategyConfig {
export class CommonControlsSectionStrategy extends ReactiveElement {
static registryDependencies: readonly LovelaceStrategyDependency[] = [];
public static async getConfigElement(): Promise<LovelaceStrategyEditor> {
await import("./hui-common-controls-section-strategy-editor");
return document.createElement(
"hui-common-controls-section-strategy-editor"
);
}
static async generate(
config: CommonControlsSectionStrategyConfig,
hass: HomeAssistant
@@ -0,0 +1,96 @@
import { mdiFormatListBulleted } from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-form/ha-form";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { ValueChangedEvent } from "../../../../types";
import type { LovelaceStrategyEditor } from "../types";
import type { CommonControlsSectionStrategyConfig } from "./common-controls-section-strategy";
const SCHEMA = [
{
name: "limit",
selector: { number: { min: 1, mode: "box" } },
},
{
name: "hide_empty",
selector: { boolean: {} },
},
{
name: "entities",
type: "expandable",
flatten: true,
iconPath: mdiFormatListBulleted,
schema: [
{
name: "include_entities",
selector: { entity: { multiple: true, reorder: true } },
},
{
name: "exclude_entities",
selector: { entity: { multiple: true } },
},
],
},
] as const satisfies readonly HaFormSchema[];
@customElement("hui-common-controls-section-strategy-editor")
export class HuiCommonControlsSectionStrategyEditor
extends LitElement
implements LovelaceStrategyEditor
{
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@state() private _config?: CommonControlsSectionStrategyConfig;
public setConfig(config: CommonControlsSectionStrategyConfig): void {
this._config = config;
}
protected render() {
if (!this._config) return nothing;
return html`
<ha-form
.data=${this._config}
.schema=${SCHEMA}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _computeLabel = (schema: SchemaUnion<typeof SCHEMA>) =>
this._localize(
`ui.panel.lovelace.editor.strategy.common_controls.${schema.name}`
);
private _computeHelper = (schema: SchemaUnion<typeof SCHEMA>) =>
schema.name === "entities" || schema.name === "hide_empty"
? ""
: this._localize(
`ui.panel.lovelace.editor.strategy.common_controls.${schema.name}_helper`
);
private _valueChanged(
ev: ValueChangedEvent<CommonControlsSectionStrategyConfig>
): void {
ev.stopPropagation();
fireEvent(this, "config-changed", {
config: { ...this._config, ...ev.detail.value },
});
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-common-controls-section-strategy-editor": HuiCommonControlsSectionStrategyEditor;
}
}
@@ -17,6 +17,7 @@ import "../../../components/ha-svg-icon";
import { maxColumnsContext } from "../common/context";
import type { LovelaceViewElement } from "../../../data/lovelace";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { isStrategySection } from "../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import type { HuiBadge } from "../badges/hui-badge";
@@ -290,6 +291,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
.lovelace=${this.lovelace}
.index=${idx}
.viewIndex=${this.index}
.isStrategy=${isStrategySection(section.config)}
>
${this._renderSection(
section,
+14 -1
View File
@@ -9769,6 +9769,8 @@
"success": "View moved successfully"
},
"section": {
"automatic": "Automatic section",
"edit_automatic": "Edit automatic section",
"add_badge": "Add badge",
"add_card": "[%key:ui::panel::lovelace::editor::edit_card::add%]",
"drop_card_create_section": "Drop card here to create a new section",
@@ -9786,8 +9788,9 @@
},
"edit_section": {
"header": "Edit section",
"tab_strategy": "Configuration",
"tab_visibility": "[%key:ui::panel::lovelace::editor::edit_view::tab_visibility%]",
"tab_settings": "[%key:ui::panel::lovelace::editor::edit_view::tab_settings%]",
"tab_appearance": "Appearance",
"edit_ui": "[%key:ui::panel::lovelace::editor::edit_view::edit_ui%]",
"edit_yaml": "[%key:ui::panel::lovelace::editor::edit_view::edit_yaml%]",
"settings": {
@@ -11125,6 +11128,16 @@
}
},
"strategy": {
"common_controls": {
"limit": "Maximum number of cards",
"limit_helper": "Defaults to 8 cards",
"entities": "Entities",
"include_entities": "Always include",
"include_entities_helper": "Shown first, in the order you choose, within the card limit",
"exclude_entities": "Exclude from suggestions",
"exclude_entities_helper": "These entities will not be suggested based on your activity",
"hide_empty": "Hide when empty"
},
"original-states": {
"areas": "Areas to display",
"hide_entities_without_area": "Hide entities without area",