Compare commits

...

1 Commits

Author SHA1 Message Date
Paul Bottein 12ea6c6c58 Add by entity suggestions to the badge picker 2026-06-18 17:53:40 +02:00
13 changed files with 694 additions and 338 deletions
+12
View File
@@ -1,6 +1,7 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
import type { LovelaceCardFeatureContext } from "../panels/lovelace/card-features/types";
import type { LovelaceBadgeConfig } from "./lovelace/config/badge";
import type { LovelaceCardConfig } from "./lovelace/config/card";
export interface CustomCardSuggestion<
@@ -10,6 +11,13 @@ export interface CustomCardSuggestion<
config: T;
}
export interface CustomBadgeSuggestion<
T extends LovelaceBadgeConfig = LovelaceBadgeConfig,
> {
label?: string;
config: T;
}
export interface CustomCardEntry {
type: string;
name?: string;
@@ -28,6 +36,10 @@ export interface CustomBadgeEntry {
description?: string;
preview?: boolean;
documentationURL?: string;
getEntitySuggestion?: (
hass: HomeAssistant,
entityId: string
) => CustomBadgeSuggestion | CustomBadgeSuggestion[] | null;
}
export interface CustomCardFeatureEntry {
@@ -0,0 +1,20 @@
import type { EntityBadgeConfig } from "../badges/types";
import type { BadgeSuggestion, BadgeSuggestionProvider } from "./types";
export const entityBadgeSuggestions: BadgeSuggestionProvider<EntityBadgeConfig> =
{
getEntitySuggestion(hass, entityId) {
const suggestions: BadgeSuggestion<EntityBadgeConfig>[] = [
{
config: { type: "entity", entity: entityId },
},
{
label: hass.localize(
"ui.panel.lovelace.editor.badge_picker.with_name"
),
config: { type: "entity", entity: entityId, show_name: true },
},
];
return suggestions;
},
};
@@ -0,0 +1,45 @@
import { ensureArray } from "../../../common/array/ensure-array";
import { customBadges } from "../../../data/lovelace_custom_cards";
import type { HomeAssistant } from "../../../types";
import { BADGE_SUGGESTION_PROVIDERS } from "./registry";
import type { BadgeSuggestion } from "./types";
export type { BadgeSuggestion, BadgeSuggestionProvider } from "./types";
export { BADGE_SUGGESTION_PROVIDERS } from "./registry";
export interface BadgeSuggestions {
core: BadgeSuggestion[];
custom: BadgeSuggestion[];
}
export const generateBadgeSuggestions = (
hass: HomeAssistant,
entityId: string | undefined
): BadgeSuggestions => {
if (!entityId || hass.states[entityId] === undefined) {
return { core: [], custom: [] };
}
const core = Object.values(BADGE_SUGGESTION_PROVIDERS).flatMap((provider) => {
try {
return ensureArray(provider.getEntitySuggestion(hass, entityId)) ?? [];
} catch (err) {
// eslint-disable-next-line no-console
console.error("Badge suggestion provider threw:", err);
return [];
}
});
const custom = customBadges.flatMap((badge) => {
if (!badge.getEntitySuggestion) return [];
try {
return ensureArray(badge.getEntitySuggestion(hass, entityId)) ?? [];
} catch (err) {
// eslint-disable-next-line no-console
console.error(
`Custom badge "${badge.type}" getEntitySuggestion threw:`,
err
);
return [];
}
});
return { core, custom };
};
@@ -0,0 +1,9 @@
import { entityBadgeSuggestions } from "./hui-entity-badge-suggestions";
import type { BadgeSuggestionProvider } from "./types";
export const BADGE_SUGGESTION_PROVIDERS: Record<
string,
BadgeSuggestionProvider
> = {
entity: entityBadgeSuggestions,
};
@@ -0,0 +1,18 @@
import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
import type { HomeAssistant } from "../../../types";
export interface BadgeSuggestion<
T extends LovelaceBadgeConfig = LovelaceBadgeConfig,
> {
label?: string;
config: T;
}
export interface BadgeSuggestionProvider<
T extends LovelaceBadgeConfig = LovelaceBadgeConfig,
> {
getEntitySuggestion(
hass: HomeAssistant,
entityId: string
): BadgeSuggestion<T> | BadgeSuggestion<T>[] | null;
}
@@ -13,14 +13,12 @@ import type {
GridSourceTypeEnergyPreference,
} from "../../../data/energy";
import { domainToName } from "../../../data/integration";
import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import { computeUserInitials } from "../../../data/user";
import type { HomeAssistant } from "../../../types";
import { HELPER_DOMAINS } from "../../config/helpers/const";
import type { EntityBadgeConfig } from "../badges/types";
import type {
AlarmPanelCardConfig,
EntitiesCardConfig,
@@ -315,23 +313,6 @@ export const computeCards = (
];
};
export const computeBadges = (
_states: HassEntities,
entityIds: string[]
): LovelaceBadgeConfig[] => {
const badges: LovelaceBadgeConfig[] = [];
for (const entityId of entityIds) {
const config: EntityBadgeConfig = {
type: "entity",
entity: entityId,
};
badges.push(config);
}
return badges;
};
const computeDefaultViewStates = (
entities: HassEntities,
entityEntries: HomeAssistant["entities"]
@@ -0,0 +1,375 @@
import { mdiClose, mdiViewGridPlus } from "@mdi/js";
import type { CSSResultGroup, 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 { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeEntityPickerDisplay } from "../../../../common/entity/compute_entity_name_display";
import "../../../../components/entity/state-badge";
import "../../../../components/ha-button";
import "../../../../components/ha-combo-box-item";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-ripple";
import "../../../../components/ha-section-title";
import "../../../../components/ha-svg-icon";
import type { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge";
import { haStyleScrollbar } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import {
generateBadgeSuggestions,
type BadgeSuggestions,
} from "../../badge-suggestions";
import type { BadgeSuggestion } from "../../badge-suggestions/types";
import "../card-editor/hui-suggestion-entity-tree";
import type { HuiSuggestionEntityTree } from "../card-editor/hui-suggestion-entity-tree";
import "./hui-suggestion-badge";
@customElement("hui-badge-suggestion-picker")
export class HuiBadgeSuggestionPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Array, attribute: false })
public prioritizedBadgeTypes?: string[];
@state() private _entityId?: string;
@state() private _narrow = false;
private _narrowMql?: MediaQueryList;
@query("hui-suggestion-entity-tree")
private _entityTree?: HuiSuggestionEntityTree;
public async focus(): Promise<void> {
await this.updateComplete;
await this._entityTree?.focus();
}
public connectedCallback(): void {
super.connectedCallback();
this._narrowMql = matchMedia("(max-width: 600px)");
this._narrow = this._narrowMql.matches;
this._narrowMql.addEventListener("change", this._handleNarrowChange);
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._narrowMql?.removeEventListener("change", this._handleNarrowChange);
this._narrowMql = undefined;
}
private _handleNarrowChange = (ev: MediaQueryListEvent) => {
this._narrow = ev.matches;
};
// Memoize on scalars so the result stays stable when only hass changes.
// Keeps hui-badge previews from re-rendering on every state tick.
private _computeSuggestions = memoizeOne(
(
entityId: string | undefined,
priorityTypesKey: string
): BadgeSuggestions => {
const { core, custom } = generateBadgeSuggestions(this.hass, entityId);
const priorityTypes = priorityTypesKey
? priorityTypesKey.split("|")
: undefined;
if (!priorityTypes?.length) return { core, custom };
const isPrioritized = (s: BadgeSuggestion) =>
priorityTypes.includes(s.config.type);
return {
core: [
...core.filter(isPrioritized),
...core.filter((s) => !isPrioritized(s)),
],
custom,
};
}
);
protected render() {
const hasEntity = !!this._entityId;
// Tree is rendered unconditionally so its state (filter, expanded
// branches, fuse index) survives the desktop/mobile and tree/suggestions
// switches.
const showTree = !this._narrow || !hasEntity;
const showMain = !this._narrow || hasEntity;
return html`
<div class=${classMap({ sidebar: true, hidden: !showTree })}>
<hui-suggestion-entity-tree
class="tree"
.hass=${this.hass}
.selectedEntityId=${this._entityId}
@entity-picked=${this._handleEntityPicked}
></hui-suggestion-entity-tree>
</div>
<div class=${classMap({ main: true, hidden: !showMain })}>
<div class="content ha-scrollbar">
${this._renderMainContent(hasEntity)}
</div>
</div>
`;
}
private _renderMainContent(
hasEntity: boolean
): TemplateResult | typeof nothing {
if (!hasEntity) return this._renderEmptyState();
const { core, custom } = this._suggestions();
return html`
${this._narrow ? this._renderSelectedEntity() : nothing}
<ha-section-title>
${this.hass.localize(
"ui.panel.lovelace.editor.badge_picker.suggestions_title"
)}
</ha-section-title>
${this._renderSuggestionsGrid(core)}
${custom.length
? html`
<ha-section-title>
${this.hass.localize(
"ui.panel.lovelace.editor.badge_picker.community_title"
)}
</ha-section-title>
${this._renderSuggestionsGrid(custom)}
`
: nothing}
${this._renderBrowseBadge()}
`;
}
private _renderBrowseBadge(): TemplateResult {
return html`
<div class="browse-badge">
<p>
${this.hass.localize(
"ui.panel.lovelace.editor.badge_picker.not_found"
)}
</p>
<ha-button appearance="plain" @click=${this._browseBadges}>
<ha-svg-icon slot="start" .path=${mdiViewGridPlus}></ha-svg-icon>
${this.hass.localize(
"ui.panel.lovelace.editor.badge_picker.browse_badges"
)}
</ha-button>
</div>
`;
}
private _renderSelectedEntity(): TemplateResult {
const stateObj = this.hass.states[this._entityId!];
const { primary, secondary } = stateObj
? computeEntityPickerDisplay(this.hass, stateObj)
: { primary: this._entityId!, secondary: undefined };
return html`
<ha-section-title>
${this.hass.localize(
"ui.panel.lovelace.editor.badge_picker.selected_entity"
)}
</ha-section-title>
<ha-combo-box-item compact class="selected-entity">
${stateObj
? html`<state-badge
slot="start"
.hass=${this.hass}
.stateObj=${stateObj}
></state-badge>`
: nothing}
<span slot="headline">${primary}</span>
${secondary
? html`<span slot="supporting-text">${secondary}</span>`
: nothing}
<ha-icon-button
slot="end"
.label=${this.hass.localize("ui.common.clear")}
.path=${mdiClose}
@click=${this._clearEntity}
></ha-icon-button>
</ha-combo-box-item>
`;
}
private _renderEmptyState(): TemplateResult {
return html`
<div class="content-empty">
<h2>
${this.hass.localize(
"ui.panel.lovelace.editor.badge_picker.content_empty_title"
)}
</h2>
<p>
${this.hass.localize(
"ui.panel.lovelace.editor.badge_picker.content_empty_description"
)}
</p>
<ha-button appearance="plain" @click=${this._browseBadges}>
<ha-svg-icon slot="start" .path=${mdiViewGridPlus}></ha-svg-icon>
${this.hass.localize(
"ui.panel.lovelace.editor.badge_picker.browse_badges"
)}
</ha-button>
</div>
`;
}
private _suggestionKeys = new WeakMap<BadgeSuggestion, string>();
private _suggestionKey = (s: BadgeSuggestion): string => {
let key = this._suggestionKeys.get(s);
if (key === undefined) {
key = JSON.stringify(s.config);
this._suggestionKeys.set(s, key);
}
return key;
};
private _renderSuggestionsGrid(
suggestions: BadgeSuggestion[]
): TemplateResult {
return html`
<div class="suggestions" @pick-badge-suggestion=${this._pickSuggestion}>
${repeat(
suggestions,
this._suggestionKey,
(s: BadgeSuggestion) => html`
<hui-suggestion-badge
.hass=${this.hass}
.suggestion=${s}
></hui-suggestion-badge>
`
)}
</div>
`;
}
private _suggestions(): BadgeSuggestions {
return this._computeSuggestions(
this._entityId,
(this.prioritizedBadgeTypes ?? []).join("|")
);
}
private _browseBadges(): void {
fireEvent(this, "browse-badges", undefined);
}
private _handleEntityPicked(ev: CustomEvent<{ entityId: string }>): void {
this._entityId = ev.detail.entityId;
}
private _clearEntity(): void {
this._entityId = undefined;
}
private _pickSuggestion(
ev: CustomEvent<{ suggestion: BadgeSuggestion }>
): void {
fireEvent(this, "badge-suggestion-picked", {
config: ev.detail.suggestion.config,
});
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
display: flex;
flex-direction: row;
min-height: 0;
}
.sidebar {
flex: 0 0 320px;
display: flex;
flex-direction: column;
border-inline-end: var(--ha-border-width-sm) solid
var(--divider-color);
min-height: 0;
overflow: hidden;
}
.tree {
flex: 1;
min-height: 0;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.content {
flex: 1;
min-height: 0;
overflow: auto;
}
.hidden {
display: none !important;
}
.suggestions {
display: grid;
gap: var(--ha-space-3);
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
padding: var(--ha-space-3);
}
.content-empty {
box-sizing: border-box;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--ha-space-3);
padding: var(--ha-space-8) var(--ha-space-4);
text-align: center;
}
.content-empty h2 {
margin: 0;
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-medium);
}
.content-empty p {
margin: 0;
max-width: 480px;
color: var(--ha-color-text-secondary);
line-height: var(--ha-line-height-expanded);
}
.browse-badge {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--ha-space-2);
padding: var(--ha-space-6) var(--ha-space-4);
}
.browse-badge p {
margin: 0;
color: var(--ha-color-text-secondary);
font-size: var(--ha-font-size-s);
}
/* Mobile master/detail: sidebar OR main is visible, never both. */
@media (max-width: 600px) {
:host {
flex-direction: column;
overflow: hidden;
}
.sidebar {
flex: 1;
border-inline-end: none;
}
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-badge-suggestion-picker": HuiBadgeSuggestionPicker;
}
interface HASSDomEvents {
"browse-badges": undefined;
"badge-suggestion-picked": { config: LovelaceBadgeConfig };
}
}
@@ -3,35 +3,24 @@ import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { cache } from "lit/directives/cache";
import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-tab-group";
import "../../../../components/ha-tab-group-tab";
import "../../../../components/ha-dialog";
import type { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { computeBadges } from "../../common/generate-lovelace-config";
import "../card-editor/hui-entity-picker-table";
import { addBadge } from "../config-util";
import { findLovelaceContainer } from "../lovelace-path";
import "./hui-badge-picker";
import "./hui-badge-suggestion-picker";
import type { CreateBadgeDialogParams } from "./show-create-badge-dialog";
import { showEditBadgeDialog } from "./show-edit-badge-dialog";
import { showSuggestBadgeDialog } from "./show-suggest-badge-dialog";
declare global {
interface HASSDomEvents {
"selected-changed": SelectedChangedEvent;
}
}
interface SelectedChangedEvent {
selectedEntities: string[];
}
@customElement("hui-dialog-create-badge")
export class HuiCreateDialogBadge
@@ -46,13 +35,17 @@ export class HuiCreateDialogBadge
@state() private _containerConfig!: LovelaceViewConfig;
@state() private _selectedEntities: string[] = [];
@state() private _currTab: "badge" | "entity" = "entity";
@state() private _currTab: "badge" | "entity" = "badge";
@state() private _narrow = false;
public async showDialog(params: CreateBadgeDialogParams): Promise<void> {
this._params = params;
this._narrow = matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
const containerConfig = findLovelaceContainer(
params.lovelaceConfig,
params.path
@@ -74,8 +67,7 @@ export class HuiCreateDialogBadge
private _dialogClosed(): void {
this._open = false;
this._params = undefined;
this._currTab = "badge";
this._selectedEntities = [];
this._currTab = "entity";
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -98,7 +90,6 @@ export class HuiCreateDialogBadge
width="large"
@keydown=${this._ignoreKeydown}
@closed=${this._dialogClosed}
class=${classMap({ table: this._currTab === "entity" })}
>
<ha-dialog-header show-border slot="header">
<ha-icon-button
@@ -109,6 +100,15 @@ export class HuiCreateDialogBadge
></ha-icon-button>
<span slot="title">${title}</span>
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
<ha-tab-group-tab
slot="nav"
.active=${this._currTab === "entity"}
panel="entity"
?autofocus=${this._narrow}
>${this.hass!.localize(
"ui.panel.lovelace.editor.badge_picker.by_entity"
)}</ha-tab-group-tab
>
<ha-tab-group-tab
slot="nav"
.active=${this._currTab === "badge"}
@@ -118,35 +118,31 @@ export class HuiCreateDialogBadge
"ui.panel.lovelace.editor.badge_picker.by_badge"
)}
</ha-tab-group-tab>
<ha-tab-group-tab
slot="nav"
.active=${this._currTab === "entity"}
panel="entity"
>${this.hass!.localize(
"ui.panel.lovelace.editor.badge_picker.by_entity"
)}</ha-tab-group-tab
>
</ha-tab-group>
</ha-dialog-header>
${cache(
this._currTab === "badge"
? html`
<hui-badge-picker
autofocus
.suggestedBadges=${this._params.suggestedBadges}
.lovelace=${this._params.lovelaceConfig}
.hass=${this.hass}
@config-changed=${this._handleBadgePicked}
></hui-badge-picker>
`
: html`
<hui-entity-picker-table
.hass=${this.hass}
.narrow=${true}
@selected-changed=${this._handleSelectedChanged}
></hui-entity-picker-table>
`
)}
<div class="body">
${cache(
this._currTab === "entity"
? html`
<hui-badge-suggestion-picker
?autofocus=${!this._narrow}
.hass=${this.hass}
.prioritizedBadgeTypes=${this._params.suggestedBadges}
@badge-suggestion-picked=${this._handleSuggestionPicked}
@browse-badges=${this._handleBrowseBadges}
></hui-badge-suggestion-picker>
`
: html`
<hui-badge-picker
?autofocus=${!this._narrow}
.suggestedBadges=${this._params.suggestedBadges}
.lovelace=${this._params.lovelaceConfig}
.hass=${this.hass}
@config-changed=${this._handleBadgePicked}
></hui-badge-picker>
`
)}
</div>
<ha-dialog-footer slot="footer">
<ha-button
@@ -156,13 +152,6 @@ export class HuiCreateDialogBadge
>
${this.hass!.localize("ui.common.cancel")}
</ha-button>
${this._selectedEntities.length
? html`
<ha-button slot="primaryAction" @click=${this._suggestBadges}>
${this.hass!.localize("ui.common.continue")}
</ha-button>
`
: ""}
</ha-dialog-footer>
</ha-dialog>
`;
@@ -181,13 +170,19 @@ export class HuiCreateDialogBadge
--dialog-z-index: 6;
}
ha-dialog.table {
--dialog-content-padding: 0;
@media (min-width: 451px) and (min-height: 501px) {
ha-dialog {
--ha-dialog-min-height: min(900px, 80vh);
--ha-dialog-max-height: var(--ha-dialog-min-height);
}
}
ha-dialog::part(body) {
overflow: hidden;
}
ha-dialog-footer {
border-top: 1px solid var(--divider-color);
}
ha-tab-group-tab {
flex: 1;
@@ -196,34 +191,41 @@ export class HuiCreateDialogBadge
width: 100%;
justify-content: center;
}
.body {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
hui-badge-picker,
hui-badge-suggestion-picker {
flex: 1;
min-height: 0;
}
hui-badge-picker {
--badge-picker-search-shape: 0;
--badge-picker-search-margin: 0;
display: flex;
flex-direction: column;
min-height: 0;
}
hui-badge-picker,
hui-entity-picker-table {
height: calc(100vh - 198px);
}
hui-entity-picker-table {
display: block;
--mdc-shape-small: 0;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
hui-badge-picker,
hui-entity-picker-table {
height: calc(100vh - 158px);
}
}
`,
];
}
private _handleBrowseBadges(): void {
this._currTab = "badge";
}
private async _handleSuggestionPicked(
ev: CustomEvent<{ config: LovelaceBadgeConfig }>
): Promise<void> {
const config = ev.detail.config;
const lovelaceConfig = this._params!.lovelaceConfig;
const containerPath = this._params!.path;
const saveConfig = this._params!.saveConfig;
const newConfig = addBadge(lovelaceConfig, containerPath, config);
await saveConfig(newConfig);
this.closeDialog();
}
private _handleBadgePicked(ev) {
const config = ev.detail.config;
if (this._params!.entities && this._params!.entities.length) {
@@ -249,13 +251,7 @@ export class HuiCreateDialogBadge
if (newTab === this._currTab) {
return;
}
this._currTab = newTab;
this._selectedEntities = [];
}
private _handleSelectedChanged(ev: CustomEvent): void {
this._selectedEntities = ev.detail.selectedEntities;
}
private _cancel(ev?: Event) {
@@ -264,20 +260,6 @@ export class HuiCreateDialogBadge
}
this.closeDialog();
}
private _suggestBadges(): void {
const badgeConfig = computeBadges(this.hass.states, this._selectedEntities);
showSuggestBadgeDialog(this, {
lovelaceConfig: this._params!.lovelaceConfig,
saveConfig: this._params!.saveConfig,
path: this._params!.path as [number],
entities: this._selectedEntities,
badgeConfig,
});
this.closeDialog();
}
}
declare global {
@@ -1,195 +0,0 @@
import deepFreeze from "deep-freeze";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-yaml-editor";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-dialog";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import type { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
import "../../badges/hui-badge";
import { addBadges } from "../config-util";
import type { LovelaceContainerPath } from "../lovelace-path";
import { parseLovelaceContainerPath } from "../lovelace-path";
import type { SuggestBadgeDialogParams } from "./show-suggest-badge-dialog";
@customElement("hui-dialog-suggest-badge")
export class HuiDialogSuggestBadge extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: SuggestBadgeDialogParams;
@state() private _open = false;
@state() private _badgeConfig?: LovelaceBadgeConfig[];
@state() private _saving = false;
@query("ha-yaml-editor") private _yamlEditor?: HaYamlEditor;
public showDialog(params: SuggestBadgeDialogParams): void {
this._params = params;
this._badgeConfig = params.badgeConfig;
this._open = true;
if (!Object.isFrozen(this._badgeConfig)) {
this._badgeConfig = deepFreeze(this._badgeConfig);
}
if (this._yamlEditor) {
this._yamlEditor.setValue(this._badgeConfig);
}
}
public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._open = false;
this._params = undefined;
this._badgeConfig = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private _renderPreview() {
if (this._badgeConfig) {
return html`
<div class="element-preview">
${this._badgeConfig.map(
(badgeConfig) => html`
<hui-badge
.hass=${this.hass}
.config=${badgeConfig}
preview
></hui-badge>
`
)}
</div>
`;
}
return nothing;
}
protected render() {
if (!this._params) {
return nothing;
}
return html`
<ha-dialog
.open=${this._open}
header-title=${this.hass!.localize(
"ui.panel.lovelace.editor.suggest_badge.header"
)}
@closed=${this._dialogClosed}
>
<div>
${this._renderPreview()}
${this._params.yaml && this._badgeConfig
? html`
<div class="editor">
<ha-yaml-editor
.defaultValue=${this._badgeConfig}
in-dialog
></ha-yaml-editor>
</div>
`
: nothing}
</div>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this.closeDialog}
autofocus
>
${this._params.yaml
? this.hass!.localize("ui.common.close")
: this.hass!.localize("ui.common.cancel")}
</ha-button>
${!this._params.yaml
? html`
<ha-button
slot="primaryAction"
@click=${this._save}
.loading=${this._saving}
>
${this.hass!.localize(
"ui.panel.lovelace.editor.suggest_badge.add"
)}
</ha-button>
`
: nothing}
</ha-dialog-footer>
</ha-dialog>
`;
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-dialog {
--dialog-z-index: 6;
}
.hidden {
display: none;
}
.element-preview {
position: relative;
display: flex;
align-items: flex-start;
flex-wrap: wrap;
justify-content: center;
gap: var(--ha-space-2);
margin: 0;
}
.editor {
padding-top: 16px;
}
`,
];
}
private _computeNewConfig(
config: LovelaceConfig,
path: LovelaceContainerPath
): LovelaceConfig {
const { viewIndex } = parseLovelaceContainerPath(path);
const newBadges = this._badgeConfig!;
return addBadges(config, [viewIndex], newBadges);
}
private async _save(): Promise<void> {
if (
!this._params?.lovelaceConfig ||
!this._params?.path ||
!this._params?.saveConfig ||
!this._badgeConfig
) {
return;
}
this._saving = true;
const newConfig = this._computeNewConfig(
this._params.lovelaceConfig,
this._params.path
);
await this._params!.saveConfig(newConfig);
this._saving = false;
showSaveSuccessToast(this, this.hass);
this.closeDialog();
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-dialog-suggest-badge": HuiDialogSuggestBadge;
}
}
@@ -0,0 +1,124 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-ripple";
import {
getCustomBadgeEntry,
isCustomType,
stripCustomPrefix,
} from "../../../../data/lovelace_custom_cards";
import type { HomeAssistant } from "../../../../types";
import type { BadgeSuggestion } from "../../badge-suggestions/types";
import "../../badges/hui-badge";
@customElement("hui-suggestion-badge")
export class HuiSuggestionBadge extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public suggestion!: BadgeSuggestion;
protected render(): TemplateResult {
const { suggestion } = this;
const type = suggestion.config.type;
let badgeName: string;
if (isCustomType(type)) {
const customType = stripCustomPrefix(type);
badgeName = getCustomBadgeEntry(customType)?.name ?? customType;
} else {
badgeName =
this.hass.localize(
`ui.panel.lovelace.editor.badge.${type}.name` as any
) || type;
}
const label = suggestion.label
? `${badgeName} - ${suggestion.label}`
: badgeName;
return html`
<div
class="badge"
tabindex="0"
role="button"
aria-label=${label}
@keydown=${this._handleKeyDown}
>
<div class="overlay" @click=${this._handleClick}></div>
<div class="badge-header">${label}</div>
<div class="preview">
<hui-badge
.hass=${this.hass}
.config=${suggestion.config}
preview
></hui-badge>
</div>
<ha-ripple></ha-ripple>
</div>
`;
}
private _handleClick(): void {
fireEvent(this, "pick-badge-suggestion", { suggestion: this.suggestion });
}
private _handleKeyDown(ev: KeyboardEvent): void {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
this._handleClick();
}
}
static readonly styles: CSSResultGroup = css`
:host {
display: block;
height: 100%;
}
.badge {
height: 100%;
display: flex;
flex-direction: column;
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
background: var(--primary-background-color);
cursor: pointer;
position: relative;
overflow: hidden;
border: var(--ha-card-border-width, var(--ha-border-width-sm)) solid
var(--ha-card-border-color, var(--divider-color));
}
.badge:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
.overlay {
position: absolute;
inset: 0;
z-index: 1;
border-radius: inherit;
}
.badge-header {
color: var(--ha-card-header-color, var(--primary-text-color));
font-family: var(--ha-card-header-font-family, inherit);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
padding: var(--ha-space-3) var(--ha-space-4);
text-align: center;
}
.preview {
pointer-events: none;
margin: var(--ha-space-4);
flex-grow: 1;
display: flex;
align-items: center;
justify-content: center;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-suggestion-badge": HuiSuggestionBadge;
}
interface HASSDomEvents {
"pick-badge-suggestion": { suggestion: BadgeSuggestion };
}
}
@@ -1,26 +0,0 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { LovelaceContainerPath } from "../lovelace-path";
export interface SuggestBadgeDialogParams {
lovelaceConfig?: LovelaceConfig;
yaml?: boolean;
saveConfig?: (config: LovelaceConfig) => void;
path?: LovelaceContainerPath;
entities?: string[]; // We pass this to create dialog when user chooses "Pick own"
badgeConfig: LovelaceBadgeConfig[]; // We can pass a suggested config
}
const importSuggestBadgeDialog = () => import("./hui-dialog-suggest-badge");
export const showSuggestBadgeDialog = (
element: HTMLElement,
suggestBadgeDialogParams: SuggestBadgeDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "hui-dialog-suggest-badge",
dialogImport: importSuggestBadgeDialog,
dialogParams: suggestBadgeDialogParams,
});
};
@@ -261,4 +261,7 @@ declare global {
interface HTMLElementTagNameMap {
"hui-entity-picker-table": HuiEntityPickerTable;
}
interface HASSDomEvents {
"selected-changed": { selectedEntities: string[] };
}
}
+9 -1
View File
@@ -10475,7 +10475,15 @@
"domain": "Domain",
"entity": "Entity",
"by_entity": "By entity",
"by_badge": "By badge"
"by_badge": "By badge",
"with_name": "With name",
"suggestions_title": "[%key:ui::panel::lovelace::editor::cardpicker::suggestions_title%]",
"community_title": "[%key:ui::panel::lovelace::editor::cardpicker::community_title%]",
"selected_entity": "[%key:ui::panel::lovelace::editor::cardpicker::selected_entity%]",
"content_empty_title": "[%key:ui::panel::lovelace::editor::cardpicker::content_empty_title%]",
"content_empty_description": "Or browse all badge types.",
"browse_badges": "Browse all badges",
"not_found": "Can't find the badge you want?"
},
"header-footer": {
"header": "Header",