Compare commits

...

8 Commits

Author SHA1 Message Date
Paul Bottein
987b925054 WIP: first version of tree view 2026-04-28 15:57:27 +02:00
Paul Bottein
26cb1b3971 Add support for translations 2026-04-27 09:18:48 +02:00
Paul Bottein
02c273b137 Add sensor tiles and improve browse button 2026-04-27 09:18:48 +02:00
Paul Bottein
4d419cd204 Add tile cards with features 2026-04-27 09:18:48 +02:00
Paul Bottein
2abf2a18a2 Refactor components to use section rendering 2026-04-27 09:18:47 +02:00
Paul Bottein
6d9dcbf1fe Improve UX 2026-04-27 09:18:47 +02:00
Paul Bottein
c8428f49e5 Fix preview 2026-04-27 09:18:47 +02:00
Paul Bottein
0bea806f67 Add entity-first card picker for dashboard 2026-04-27 09:18:47 +02:00
16 changed files with 2204 additions and 285 deletions

View File

@@ -445,10 +445,13 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
}
wa-popover::part(body) {
width: max(var(--body-width), 250px);
width: max(
var(--body-width),
var(--ha-generic-picker-min-width, 250px)
);
max-width: var(
--ha-generic-picker-max-width,
max(var(--body-width), 250px)
max(var(--body-width), var(--ha-generic-picker-min-width, 250px))
);
max-height: 500px;
height: 70vh;

View File

@@ -0,0 +1,174 @@
import type { HomeAssistant } from "../../../types";
import { supportsAlarmModesCardFeature } from "./hui-alarm-modes-card-feature";
import { supportsAreaControlsCardFeature } from "./hui-area-controls-card-feature";
import { supportsBarGaugeCardFeature } from "./hui-bar-gauge-card-feature";
import { supportsButtonCardFeature } from "./hui-button-card-feature";
import { supportsClimateFanModesCardFeature } from "./hui-climate-fan-modes-card-feature";
import { supportsClimateHvacModesCardFeature } from "./hui-climate-hvac-modes-card-feature";
import { supportsClimatePresetModesCardFeature } from "./hui-climate-preset-modes-card-feature";
import { supportsClimateSwingHorizontalModesCardFeature } from "./hui-climate-swing-horizontal-modes-card-feature";
import { supportsClimateSwingModesCardFeature } from "./hui-climate-swing-modes-card-feature";
import { supportsCounterActionsCardFeature } from "./hui-counter-actions-card-feature";
import { supportsCoverOpenCloseCardFeature } from "./hui-cover-open-close-card-feature";
import { supportsCoverPositionFavoriteCardFeature } from "./hui-cover-position-favorite-card-feature";
import { supportsCoverPositionCardFeature } from "./hui-cover-position-card-feature";
import { supportsCoverTiltCardFeature } from "./hui-cover-tilt-card-feature";
import { supportsCoverTiltFavoriteCardFeature } from "./hui-cover-tilt-favorite-card-feature";
import { supportsCoverTiltPositionCardFeature } from "./hui-cover-tilt-position-card-feature";
import { supportsDailyForecastCardFeature } from "./hui-daily-forecast-card-feature";
import { supportsDateSetCardFeature } from "./hui-date-set-card-feature";
import { supportsFanDirectionCardFeature } from "./hui-fan-direction-card-feature";
import { supportsFanOscilatteCardFeature } from "./hui-fan-oscillate-card-feature";
import { supportsFanPresetModesCardFeature } from "./hui-fan-preset-modes-card-feature";
import { supportsFanSpeedCardFeature } from "./hui-fan-speed-card-feature";
import { supportsHourlyForecastCardFeature } from "./hui-hourly-forecast-card-feature";
import { supportsHumidifierModesCardFeature } from "./hui-humidifier-modes-card-feature";
import { supportsHumidifierToggleCardFeature } from "./hui-humidifier-toggle-card-feature";
import { supportsLawnMowerCommandCardFeature } from "./hui-lawn-mower-commands-card-feature";
import { supportsLightBrightnessCardFeature } from "./hui-light-brightness-card-feature";
import { supportsLightColorFavoritesCardFeature } from "./hui-light-color-favorites-card-feature";
import { supportsLightColorTempCardFeature } from "./hui-light-color-temp-card-feature";
import { supportsLockCommandsCardFeature } from "./hui-lock-commands-card-feature";
import { supportsLockOpenDoorCardFeature } from "./hui-lock-open-door-card-feature";
import { supportsMediaPlayerPlaybackCardFeature } from "./hui-media-player-playback-card-feature";
import { supportsMediaPlayerSoundModeCardFeature } from "./hui-media-player-sound-mode-card-feature";
import { supportsMediaPlayerSourceCardFeature } from "./hui-media-player-source-card-feature";
import { supportsMediaPlayerVolumeButtonsCardFeature } from "./hui-media-player-volume-buttons-card-feature";
import { supportsMediaPlayerVolumeSliderCardFeature } from "./hui-media-player-volume-slider-card-feature";
import { supportsNumericInputCardFeature } from "./hui-numeric-input-card-feature";
import { supportsSelectOptionsCardFeature } from "./hui-select-options-card-feature";
import { supportsTargetHumidityCardFeature } from "./hui-target-humidity-card-feature";
import { supportsTargetTemperatureCardFeature } from "./hui-target-temperature-card-feature";
import { supportsToggleCardFeature } from "./hui-toggle-card-feature";
import { supportsTrendGraphCardFeature } from "./hui-trend-graph-card-feature";
import { supportsUpdateActionsCardFeature } from "./hui-update-actions-card-feature";
import { supportsVacuumCommandsCardFeature } from "./hui-vacuum-commands-card-feature";
import { supportsValveOpenCloseCardFeature } from "./hui-valve-open-close-card-feature";
import { supportsValvePositionFavoriteCardFeature } from "./hui-valve-position-favorite-card-feature";
import { supportsValvePositionCardFeature } from "./hui-valve-position-card-feature";
import { supportsWaterHeaterOperationModesCardFeature } from "./hui-water-heater-operation-modes-card-feature";
import type {
LovelaceCardFeatureConfig,
LovelaceCardFeatureContext,
} from "./types";
export type FeatureType = LovelaceCardFeatureConfig["type"];
export type SupportsFeature = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext
) => boolean;
export const UI_FEATURE_TYPES = [
"alarm-modes",
"area-controls",
"bar-gauge",
"button",
"climate-fan-modes",
"climate-hvac-modes",
"climate-preset-modes",
"climate-swing-modes",
"climate-swing-horizontal-modes",
"counter-actions",
"cover-open-close",
"cover-position-favorite",
"cover-position",
"cover-tilt-favorite",
"cover-tilt-position",
"cover-tilt",
"daily-forecast",
"date-set",
"fan-direction",
"fan-oscillate",
"fan-preset-modes",
"fan-speed",
"hourly-forecast",
"humidifier-modes",
"humidifier-toggle",
"lawn-mower-commands",
"light-brightness",
"light-color-temp",
"light-color-favorites",
"lock-commands",
"lock-open-door",
"media-player-playback",
"media-player-sound-mode",
"media-player-source",
"media-player-volume-buttons",
"media-player-volume-slider",
"numeric-input",
"select-options",
"trend-graph",
"target-humidity",
"target-temperature",
"toggle",
"update-actions",
"vacuum-commands",
"valve-open-close",
"valve-position-favorite",
"valve-position",
"water-heater-operation-modes",
] as const satisfies readonly FeatureType[];
export type UiFeatureType = (typeof UI_FEATURE_TYPES)[number];
export const SUPPORTS_FEATURE_TYPES: Record<UiFeatureType, SupportsFeature> = {
"alarm-modes": supportsAlarmModesCardFeature,
"area-controls": supportsAreaControlsCardFeature,
"bar-gauge": supportsBarGaugeCardFeature,
button: supportsButtonCardFeature,
"climate-fan-modes": supportsClimateFanModesCardFeature,
"climate-swing-modes": supportsClimateSwingModesCardFeature,
"climate-swing-horizontal-modes":
supportsClimateSwingHorizontalModesCardFeature,
"climate-hvac-modes": supportsClimateHvacModesCardFeature,
"climate-preset-modes": supportsClimatePresetModesCardFeature,
"counter-actions": supportsCounterActionsCardFeature,
"cover-open-close": supportsCoverOpenCloseCardFeature,
"cover-position-favorite": supportsCoverPositionFavoriteCardFeature,
"cover-position": supportsCoverPositionCardFeature,
"cover-tilt-favorite": supportsCoverTiltFavoriteCardFeature,
"cover-tilt-position": supportsCoverTiltPositionCardFeature,
"cover-tilt": supportsCoverTiltCardFeature,
"daily-forecast": (hass, context) =>
supportsDailyForecastCardFeature(
context.entity_id ? hass.states[context.entity_id] : undefined
),
"date-set": supportsDateSetCardFeature,
"fan-direction": supportsFanDirectionCardFeature,
"fan-oscillate": supportsFanOscilatteCardFeature,
"fan-preset-modes": supportsFanPresetModesCardFeature,
"fan-speed": supportsFanSpeedCardFeature,
"hourly-forecast": supportsHourlyForecastCardFeature,
"humidifier-modes": supportsHumidifierModesCardFeature,
"humidifier-toggle": supportsHumidifierToggleCardFeature,
"lawn-mower-commands": supportsLawnMowerCommandCardFeature,
"light-brightness": supportsLightBrightnessCardFeature,
"light-color-temp": supportsLightColorTempCardFeature,
"light-color-favorites": supportsLightColorFavoritesCardFeature,
"lock-commands": supportsLockCommandsCardFeature,
"lock-open-door": supportsLockOpenDoorCardFeature,
"media-player-playback": supportsMediaPlayerPlaybackCardFeature,
"media-player-sound-mode": supportsMediaPlayerSoundModeCardFeature,
"media-player-source": supportsMediaPlayerSourceCardFeature,
"media-player-volume-buttons": supportsMediaPlayerVolumeButtonsCardFeature,
"media-player-volume-slider": supportsMediaPlayerVolumeSliderCardFeature,
"numeric-input": supportsNumericInputCardFeature,
"select-options": supportsSelectOptionsCardFeature,
"trend-graph": supportsTrendGraphCardFeature,
"target-humidity": supportsTargetHumidityCardFeature,
"target-temperature": supportsTargetTemperatureCardFeature,
toggle: supportsToggleCardFeature,
"update-actions": supportsUpdateActionsCardFeature,
"vacuum-commands": supportsVacuumCommandsCardFeature,
"valve-open-close": supportsValveOpenCloseCardFeature,
"valve-position-favorite": supportsValvePositionFavoriteCardFeature,
"valve-position": supportsValvePositionCardFeature,
"water-heater-operation-modes": supportsWaterHeaterOperationModesCardFeature,
};
export const supportsFeatureType = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext,
type: UiFeatureType
): boolean => SUPPORTS_FEATURE_TYPES[type](hass, context);

View File

@@ -0,0 +1,17 @@
import { computeDomain } from "../../../common/entity/compute_domain";
import type { CalendarCardConfig } from "../cards/types";
import type { CardSuggestionProvider } from "./types";
export const calendarCardSuggestions: CardSuggestionProvider<CalendarCardConfig> =
{
getEntitySuggestion(hass, entityId) {
if (computeDomain(entityId) !== "calendar") return null;
return {
id: "calendar",
label: hass.localize(
"ui.panel.lovelace.editor.cardpicker.suggestions.calendar"
),
config: { type: "calendar", entities: [entityId] },
};
},
};

View File

@@ -0,0 +1,222 @@
import { computeDomain } from "../../../common/entity/compute_domain";
import type { HomeAssistant } from "../../../types";
import {
SUPPORTS_FEATURE_TYPES,
type UiFeatureType,
} from "../card-features/registry";
import type { LovelaceCardFeatureConfig } from "../card-features/types";
import type { TileCardConfig } from "../cards/types";
import type { CardSuggestion, CardSuggestionProvider } from "./types";
interface TileVariant {
id: string;
features: UiFeatureType[];
}
const LABEL_PREFIX = "ui.panel.lovelace.editor.cardpicker.suggestions.";
const TILE_VARIANT: TileVariant = { id: "tile", features: [] };
const TILE_TOGGLE_VARIANT: TileVariant = {
id: "tile_toggle",
features: ["toggle"],
};
const SELECT_VARIANTS: TileVariant[] = [
TILE_VARIANT,
{ id: "tile_options", features: ["select-options"] },
];
const NUMERIC_INPUT_VARIANTS: TileVariant[] = [
TILE_VARIANT,
{ id: "tile_numeric_input", features: ["numeric-input"] },
];
const DATE_VARIANTS: TileVariant[] = [
TILE_VARIANT,
{ id: "tile_date_picker", features: ["date-set"] },
];
const DOMAIN_VARIANTS: Record<string, TileVariant[]> = {
light: [
TILE_VARIANT,
{ id: "tile_brightness", features: ["light-brightness"] },
TILE_TOGGLE_VARIANT,
{ id: "tile_color_temperature", features: ["light-color-temp"] },
{ id: "tile_favorite_colors", features: ["light-color-favorites"] },
],
cover: [
TILE_VARIANT,
{ id: "tile_open_close", features: ["cover-open-close"] },
{ id: "tile_position", features: ["cover-position"] },
{ id: "tile_tilt", features: ["cover-tilt"] },
],
climate: [
TILE_VARIANT,
{ id: "tile_hvac_modes", features: ["climate-hvac-modes"] },
],
media_player: [
TILE_VARIANT,
{ id: "tile_playback_controls", features: ["media-player-playback"] },
{ id: "tile_volume_slider", features: ["media-player-volume-slider"] },
],
fan: [
TILE_VARIANT,
{ id: "tile_speed", features: ["fan-speed"] },
{ id: "tile_preset_modes", features: ["fan-preset-modes"] },
],
switch: [TILE_VARIANT, TILE_TOGGLE_VARIANT],
input_boolean: [TILE_VARIANT, TILE_TOGGLE_VARIANT],
lock: [
TILE_VARIANT,
{ id: "tile_lock_commands", features: ["lock-commands"] },
],
humidifier: [
TILE_VARIANT,
{ id: "tile_humidifier_toggle", features: ["humidifier-toggle"] },
{ id: "tile_humidifier_modes", features: ["humidifier-modes"] },
],
vacuum: [
TILE_VARIANT,
{ id: "tile_vacuum_commands", features: ["vacuum-commands"] },
],
lawn_mower: [
TILE_VARIANT,
{ id: "tile_mower_commands", features: ["lawn-mower-commands"] },
],
valve: [
TILE_VARIANT,
{ id: "tile_open_close", features: ["valve-open-close"] },
{ id: "tile_position", features: ["valve-position"] },
],
alarm_control_panel: [
TILE_VARIANT,
{ id: "tile_alarm_modes", features: ["alarm-modes"] },
],
counter: [
TILE_VARIANT,
{ id: "tile_counter_actions", features: ["counter-actions"] },
],
input_select: SELECT_VARIANTS,
select: SELECT_VARIANTS,
input_number: NUMERIC_INPUT_VARIANTS,
number: NUMERIC_INPUT_VARIANTS,
input_datetime: DATE_VARIANTS,
date: DATE_VARIANTS,
update: [
TILE_VARIANT,
{ id: "tile_update_actions", features: ["update-actions"] },
],
water_heater: [
TILE_VARIANT,
{ id: "tile_operation_modes", features: ["water-heater-operation-modes"] },
],
weather: [
TILE_VARIANT,
{ id: "tile_hourly_forecast", features: ["hourly-forecast"] },
],
};
const DEFAULT_VARIANT: TileVariant = TILE_VARIANT;
const SENSOR_TREND_DEVICE_CLASSES = new Set<string>([
"battery",
"carbon_dioxide",
"carbon_monoxide",
"humidity",
"illuminance",
"pm1",
"pm10",
"pm25",
"power",
"pressure",
"temperature",
"volatile_organic_compounds",
"wind_speed",
]);
const SENSOR_TREND_VARIANTS: TileVariant[] = [
TILE_VARIANT,
{ id: "tile_trend_graph", features: ["trend-graph"] },
];
const EXCLUDED_DOMAINS = ["calendar", "todo"];
const getVariants = (
hass: HomeAssistant,
entityId: string
): TileVariant[] | undefined => {
const domain = computeDomain(entityId);
if (EXCLUDED_DOMAINS.includes(domain)) return undefined;
if (domain === "sensor") {
const deviceClass = hass.states[entityId]?.attributes.device_class;
if (deviceClass && SENSOR_TREND_DEVICE_CLASSES.has(deviceClass)) {
return SENSOR_TREND_VARIANTS;
}
return undefined;
}
return DOMAIN_VARIANTS[domain];
};
const buildTileConfig = (
entityId: string,
features: UiFeatureType[]
): TileCardConfig => {
const config: TileCardConfig = { type: "tile", entity: entityId };
if (features.length) {
config.features = features.map(
(type) => ({ type }) as LovelaceCardFeatureConfig
);
}
return config;
};
const allFeaturesSupported = (
hass: HomeAssistant,
entityId: string,
features: UiFeatureType[]
): boolean =>
features.every((type) => {
const supports = SUPPORTS_FEATURE_TYPES[type];
if (!supports) {
return false;
}
try {
return supports(hass, { entity_id: entityId });
} catch {
return false;
}
});
export const pickBestTileFeatures = (
hass: HomeAssistant,
entityId: string
): UiFeatureType[] => {
const variants = getVariants(hass, entityId);
if (!variants) return [];
for (const variant of variants) {
if (
variant.features.length &&
allFeaturesSupported(hass, entityId, variant.features)
) {
return variant.features;
}
}
return [];
};
export const tileCardSuggestions: CardSuggestionProvider<TileCardConfig> = {
getEntitySuggestion(hass, entityId) {
if (EXCLUDED_DOMAINS.includes(computeDomain(entityId))) return null;
const variants = getVariants(hass, entityId) ?? [DEFAULT_VARIANT];
const suggestions: CardSuggestion<TileCardConfig>[] = [];
for (const variant of variants) {
if (!allFeaturesSupported(hass, entityId, variant.features)) continue;
suggestions.push({
id: variant.id,
label: hass.localize(`${LABEL_PREFIX}${variant.id}`),
config: buildTileConfig(entityId, variant.features),
});
}
return suggestions.length ? suggestions : null;
},
};

View File

@@ -0,0 +1,17 @@
import { computeDomain } from "../../../common/entity/compute_domain";
import type { TodoListCardConfig } from "../cards/types";
import type { CardSuggestionProvider } from "./types";
export const todoListCardSuggestions: CardSuggestionProvider<TodoListCardConfig> =
{
getEntitySuggestion(hass, entityId) {
if (computeDomain(entityId) !== "todo") return null;
return {
id: "todo-list",
label: hass.localize(
"ui.panel.lovelace.editor.cardpicker.suggestions.todo_list"
),
config: { type: "todo-list", entity: entityId },
};
},
};

View File

@@ -0,0 +1,11 @@
import { calendarCardSuggestions } from "./hui-calendar-card-suggestions";
import { tileCardSuggestions } from "./hui-tile-card-suggestions";
import { todoListCardSuggestions } from "./hui-todo-list-card-suggestions";
import type { CardSuggestionProvider } from "./types";
export const CARD_SUGGESTION_PROVIDERS: Record<string, CardSuggestionProvider> =
{
tile: tileCardSuggestions,
calendar: calendarCardSuggestions,
"todo-list": todoListCardSuggestions,
};

View File

@@ -0,0 +1,21 @@
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { HomeAssistant } from "../../../types";
export interface CardSuggestion<
T extends LovelaceCardConfig = LovelaceCardConfig,
> {
id: string;
label: string;
config: T;
/** When true, inner `cards` are inlined into a section target instead of nested in the wrapper. */
flattenInSection?: boolean;
}
export interface CardSuggestionProvider<
T extends LovelaceCardConfig = LovelaceCardConfig,
> {
getEntitySuggestion(
hass: HomeAssistant,
entityId: string
): CardSuggestion<T> | CardSuggestion<T>[] | null;
}

View File

@@ -0,0 +1,92 @@
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { HomeAssistant } from "../../../types";
import { pickBestTileFeatures } from "../card-suggestions/hui-tile-card-suggestions";
import { CARD_SUGGESTION_PROVIDERS } from "../card-suggestions/registry";
import type { CardSuggestion } from "../card-suggestions/types";
export type { CardSuggestion } from "../card-suggestions/types";
const GRID_COLUMNS = 2;
const buildMultiEntitySuggestions = (
hass: HomeAssistant,
entityIds: string[]
): CardSuggestion[] => {
const plainTiles: LovelaceCardConfig[] = entityIds.map((id) => ({
type: "tile",
entity: id,
}));
const featuredTiles: LovelaceCardConfig[] = entityIds.map((id) => {
const features = pickBestTileFeatures(hass, id);
return features.length
? {
type: "tile",
entity: id,
features: features.map((type) => ({ type })),
}
: { type: "tile", entity: id };
});
const anyFeature = featuredTiles.some(
(tile) => (tile as { features?: unknown[] }).features?.length
);
const prefix = "ui.panel.lovelace.editor.cardpicker.suggestions.";
return [
{
id: "tile-cards",
label: hass.localize(`${prefix}tile_cards`),
flattenInSection: true,
config: {
type: "grid",
columns: GRID_COLUMNS,
square: false,
cards: plainTiles,
},
},
...(anyFeature
? [
{
id: "tile-cards-with-features",
label: hass.localize(`${prefix}featured_tile_cards`),
flattenInSection: true,
config: {
type: "grid",
columns: GRID_COLUMNS,
square: false,
cards: featuredTiles,
},
},
]
: []),
{
id: "entities-card",
label: hass.localize(`${prefix}entities_card`),
config: { type: "entities", entities: entityIds },
},
];
};
const collectEntitySuggestions = (
hass: HomeAssistant,
entityId: string
): CardSuggestion[] =>
Object.values(CARD_SUGGESTION_PROVIDERS).flatMap((provider) => {
try {
const result = provider.getEntitySuggestion(hass, entityId);
if (!result) return [];
return Array.isArray(result) ? result : [result];
} catch {
return [];
}
});
export const generateCardSuggestions = (
hass: HomeAssistant,
entityIds: string[]
): CardSuggestion[] => {
const validIds = entityIds.filter((id) => hass.states[id] !== undefined);
if (validIds.length === 0) return [];
if (validIds.length === 1) return collectEntitySuggestions(hass, validIds[0]);
return buildMultiEntitySuggestions(hass, validIds);
};

View File

@@ -3,7 +3,6 @@ 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";
@@ -11,36 +10,18 @@ import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-tab-group";
import "../../../../components/ha-tab-group-tab";
import "../../../../components/ha-dialog";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import { isStrategySection } from "../../../../data/lovelace/config/section";
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 {
computeCards,
computeSection,
} from "../../common/generate-lovelace-config";
import { addCard } from "../config-util";
import {
findLovelaceContainer,
parseLovelaceContainerPath,
} from "../lovelace-path";
import { findLovelaceContainer } from "../lovelace-path";
import "./hui-card-picker";
import "./hui-entity-picker-table";
import "./hui-recipe-picker";
import type { CreateCardDialogParams } from "./show-create-card-dialog";
import { showEditCardDialog } from "./show-edit-card-dialog";
import { showSuggestCardDialog } from "./show-suggest-card-dialog";
declare global {
interface HASSDomEvents {
"selected-changed": SelectedChangedEvent;
}
}
interface SelectedChangedEvent {
selectedEntities: string[];
}
@customElement("hui-dialog-create-card")
export class HuiCreateDialogCard
@@ -57,9 +38,7 @@ export class HuiCreateDialogCard
| LovelaceViewConfig
| LovelaceSectionConfig;
@state() private _selectedEntities: string[] = [];
@state() private _currTab: "card" | "entity" = "card";
@state() private _currTab: "card" | "entity" = "entity";
@state() private _narrow = false;
@@ -91,8 +70,7 @@ export class HuiCreateDialogCard
private _dialogClosed(): void {
this._open = false;
this._params = undefined;
this._currTab = "card";
this._selectedEntities = [];
this._currTab = "entity";
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -116,7 +94,6 @@ export class HuiCreateDialogCard
width="large"
@keydown=${this._ignoreKeydown}
@closed=${this._dialogClosed}
class=${classMap({ table: this._currTab === "entity" })}
>
<ha-dialog-header show-border slot="header">
<ha-icon-button
@@ -130,31 +107,43 @@ export class HuiCreateDialogCard
${!this._params.saveCard
? html`
<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.cardpicker.by_entity"
)}</ha-tab-group-tab
>
<ha-tab-group-tab
slot="nav"
.active=${this._currTab === "card"}
panel="card"
?autofocus=${this._narrow}
>
${this.hass!.localize(
"ui.panel.lovelace.editor.cardpicker.by_card"
)}
</ha-tab-group-tab>
<ha-tab-group-tab
slot="nav"
.active=${this._currTab === "entity"}
panel="entity"
>${this.hass!.localize(
"ui.panel.lovelace.editor.cardpicker.by_entity"
)}</ha-tab-group-tab
>
</ha-tab-group>
`
: nothing}
</ha-dialog-header>
${cache(
this._currTab === "card"
this._currTab === "entity"
? html`
<hui-recipe-picker
.hass=${this.hass}
.sectionConfig=${this._params!.path.length === 2
? (this._containerConfig as LovelaceSectionConfig)
: undefined}
.prioritizedCardTypes=${this._params.suggestedCards}
@config-changed=${this._handleCardPicked}
@recipe-cards-picked=${this._handleRecipeCardsPicked}
@recipe-browse-cards=${this._handleBrowseCards}
></hui-recipe-picker>
`
: html`
<hui-card-picker
?autofocus=${!this._narrow}
.suggestedCards=${this._params.suggestedCards}
@@ -163,13 +152,6 @@ export class HuiCreateDialogCard
@config-changed=${this._handleCardPicked}
></hui-card-picker>
`
: html`
<hui-entity-picker-table
.hass=${this.hass}
narrow
@selected-changed=${this._handleSelectedChanged}
></hui-entity-picker-table>
`
)}
<ha-dialog-footer slot="footer">
@@ -180,13 +162,6 @@ export class HuiCreateDialogCard
>
${this.hass!.localize("ui.common.cancel")}
</ha-button>
${this._selectedEntities.length
? html`
<ha-button slot="primaryAction" @click=${this._suggestCards}>
${this.hass!.localize("ui.common.continue")}
</ha-button>
`
: ""}
</ha-dialog-footer>
</ha-dialog>
`;
@@ -205,10 +180,6 @@ export class HuiCreateDialogCard
--dialog-z-index: 6;
}
ha-dialog.table {
--dialog-content-padding: 0;
}
ha-dialog::part(body) {
overflow: hidden;
}
@@ -227,18 +198,13 @@ export class HuiCreateDialogCard
}
hui-card-picker,
hui-entity-picker-table {
hui-recipe-picker {
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-card-picker,
hui-entity-picker-table {
hui-recipe-picker {
height: calc(100vh - 158px);
}
}
@@ -246,6 +212,29 @@ export class HuiCreateDialogCard
];
}
private _handleBrowseCards(): void {
this._currTab = "card";
}
private async _handleRecipeCardsPicked(
ev: CustomEvent<{ cards: LovelaceCardConfig[] }>
): Promise<void> {
const cards = ev.detail.cards;
if (!cards.length || this._params!.saveCard) {
return;
}
const containerPath = this._params!.path;
const saveConfig = this._params!.saveConfig;
let newConfig = this._params!.lovelaceConfig;
for (const card of cards) {
newConfig = addCard(newConfig, containerPath, card);
}
await saveConfig(newConfig);
this.closeDialog();
}
private _handleCardPicked(ev) {
const config = ev.detail.config;
if (this._params!.entities && this._params!.entities.length) {
@@ -295,13 +284,7 @@ export class HuiCreateDialogCard
if (newTab === this._currTab) {
return;
}
this._currTab = newTab;
this._selectedEntities = [];
}
private _handleSelectedChanged(ev: CustomEvent): void {
this._selectedEntities = ev.detail.selectedEntities;
}
private _cancel(ev?: Event) {
@@ -310,45 +293,6 @@ export class HuiCreateDialogCard
}
this.closeDialog();
}
private _suggestCards(): void {
const cardConfig = computeCards(this.hass, this._selectedEntities, {});
let sectionOptions: Partial<LovelaceSectionConfig> = {};
const { viewIndex, sectionIndex } = parseLovelaceContainerPath(
this._params!.path
);
const isSection = sectionIndex !== undefined;
// If we are in a section, we want to keep the section options for the preview
if (isSection) {
const containerConfig = findLovelaceContainer(
this._params!.lovelaceConfig!,
[viewIndex, sectionIndex]
);
if (!isStrategySection(containerConfig)) {
const { cards, title, ...rest } = containerConfig;
sectionOptions = rest;
}
}
const sectionConfig = computeSection(
this._selectedEntities,
sectionOptions
);
showSuggestCardDialog(this, {
lovelaceConfig: this._params!.lovelaceConfig,
saveConfig: this._params!.saveConfig,
path: this._params!.path as [number],
entities: this._selectedEntities,
cardConfig,
sectionConfig,
});
this.closeDialog();
}
}
declare global {

View File

@@ -0,0 +1,748 @@
import {
mdiCheck,
mdiChevronDown,
mdiChevronRight,
mdiTextureBox,
} from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { computeEntityName } from "../../../../common/entity/compute_entity_name";
import { computeStateName } from "../../../../common/entity/compute_state_name";
import { stringCompare } from "../../../../common/string/compare";
import "../../../../components/entity/state-badge";
import "../../../../components/ha-combo-box-item";
import "../../../../components/ha-domain-icon";
import "../../../../components/ha-floor-icon";
import "../../../../components/ha-icon";
import "../../../../components/ha-section-title";
import "../../../../components/ha-svg-icon";
import "../../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../../components/input/ha-input-search";
import type { ConfigEntry } from "../../../../data/config_entries";
import { getConfigEntries } from "../../../../data/config_entries";
import { isUnavailableState } from "../../../../data/entity/entity";
import { getFloorAreaLookup } from "../../../../data/floor_registry";
import { domainToName } from "../../../../data/integration";
import { haStyleScrollbar } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
declare global {
interface HASSDomEvents {
"entity-toggled": { entityId: string };
}
}
interface DeviceNode {
id: string;
name: string;
entityIds: string[];
}
interface AreaNode {
id: string;
name: string;
icon?: string;
directEntityIds: string[];
devices: DeviceNode[];
}
interface FloorNode {
id: string;
name: string;
icon: string | null;
level: number | null;
areas: AreaNode[];
}
interface DomainGroup {
domain: string;
name: string;
entityIds: string[];
}
interface TreeData {
floors: FloorNode[];
unassignedAreas: AreaNode[];
otherDevices: DeviceNode[];
otherDomains: DomainGroup[];
}
const NO_FLOOR_ID = "__no_floor__";
const OTHER_GROUP_ID = "__other__";
const SEP = "~";
const floorKey = (id: string) => `f|${id}`;
const areaKey = (parent: string, id: string) => `${parent}${SEP}a|${id}`;
const deviceKey = (parent: string, id: string) => `${parent}${SEP}d|${id}`;
const domainKey = (parent: string, domain: string) =>
`${parent}${SEP}dom|${domain}`;
const LEAD_AREA = "var(--ha-space-8)";
const LEAD_DEVICE = "var(--ha-space-12)";
const LEAD_ENTITY_AREA = "var(--ha-space-12)";
const LEAD_ENTITY_DEVICE = "var(--ha-space-16)";
@customElement("hui-recipe-entity-tree")
export class HuiRecipeEntityTree extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false, type: Array })
public selectedEntityIds: string[] = [];
@state() private _filter = "";
@state() private _expanded: Record<string, boolean> = {};
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
public connectedCallback(): void {
super.connectedCallback();
if (this.hass && !Object.keys(this._configEntryLookup).length) {
this._loadConfigEntries();
}
}
private async _loadConfigEntries() {
if (!this.hass) return;
try {
const entries = await getConfigEntries(this.hass);
const lookup: Record<string, ConfigEntry> = {};
for (const entry of entries) {
lookup[entry.entry_id] = entry;
}
this._configEntryLookup = lookup;
} catch (_err) {
// No-op: device rows will fall back to a generic icon
}
}
private _deviceDomain(deviceId: string): string | undefined {
const device = this.hass?.devices[deviceId];
if (!device?.primary_config_entry) return undefined;
return this._configEntryLookup[device.primary_config_entry]?.domain;
}
private _buildTree = memoizeOne(
(
states: HomeAssistant["states"],
entityReg: HomeAssistant["entities"],
deviceReg: HomeAssistant["devices"],
areaReg: HomeAssistant["areas"],
floorReg: HomeAssistant["floors"],
language: string | undefined
): TreeData => {
const areaDirectEntities = new Map<string, string[]>();
const areaDeviceEntities = new Map<string, Map<string, string[]>>();
const orphanDeviceEntities = new Map<string, string[]>();
const orphanByDomain = new Map<string, string[]>();
for (const entityId of Object.keys(states)) {
const stateObj = states[entityId];
if (!stateObj || isUnavailableState(stateObj.state)) continue;
const entry = entityReg[entityId];
if (entry?.hidden) continue;
const device = entry?.device_id
? deviceReg[entry.device_id]
: undefined;
const areaId = entry?.area_id ?? device?.area_id;
if (!areaId || !areaReg[areaId]) {
if (device) {
const list = orphanDeviceEntities.get(device.id) ?? [];
list.push(entityId);
orphanDeviceEntities.set(device.id, list);
} else {
const domain = computeDomain(entityId);
const list = orphanByDomain.get(domain) ?? [];
list.push(entityId);
orphanByDomain.set(domain, list);
}
continue;
}
const groupUnderDevice = device && !entry?.area_id;
if (groupUnderDevice) {
const byDevice = areaDeviceEntities.get(areaId) ?? new Map();
const list = byDevice.get(device!.id) ?? [];
list.push(entityId);
byDevice.set(device!.id, list);
areaDeviceEntities.set(areaId, byDevice);
} else {
const list = areaDirectEntities.get(areaId) ?? [];
list.push(entityId);
areaDirectEntities.set(areaId, list);
}
}
const sortByName = (a: string, b: string) => {
const an = computeStateName(states[a]) || a;
const bn = computeStateName(states[b]) || b;
return stringCompare(an, bn, language);
};
const buildAreaNode = (areaId: string): AreaNode | undefined => {
const area = areaReg[areaId];
if (!area) return undefined;
const directIds = (areaDirectEntities.get(areaId) ?? []).sort(
sortByName
);
const byDevice = areaDeviceEntities.get(areaId);
const devices: DeviceNode[] = byDevice
? [...byDevice.entries()]
.map(([id, ids]) => {
const device = deviceReg[id];
return {
id,
name: (device ? computeDeviceName(device) : undefined) ?? id,
entityIds: ids.sort(sortByName),
};
})
.sort((a, b) => stringCompare(a.name, b.name, language))
: [];
if (!directIds.length && !devices.length) return undefined;
return {
id: area.area_id,
name: computeAreaName(area) ?? area.area_id,
icon: area.icon ?? undefined,
directEntityIds: directIds,
devices,
};
};
const areas = Object.values(areaReg);
const floors = Object.values(floorReg);
const floorAreaLookup = getFloorAreaLookup(areas);
const floorNodes: FloorNode[] = floors
.map((floor) => {
const areaList = (floorAreaLookup[floor.floor_id] ?? [])
.map((a) => buildAreaNode(a.area_id))
.filter((a): a is AreaNode => !!a)
.sort((a, b) => stringCompare(a.name, b.name, language));
if (!areaList.length) return undefined;
return {
id: floor.floor_id,
name: floor.name,
icon: floor.icon,
level: floor.level,
areas: areaList,
};
})
.filter((f): f is FloorNode => !!f)
.sort((a, b) => stringCompare(a.name, b.name, language));
const unassignedAreas = areas
.filter((a) => !a.floor_id || !floorReg[a.floor_id])
.map((a) => buildAreaNode(a.area_id))
.filter((a): a is AreaNode => !!a)
.sort((a, b) => stringCompare(a.name, b.name, language));
const otherDevices: DeviceNode[] = [...orphanDeviceEntities.entries()]
.map(([id, ids]) => {
const device = deviceReg[id];
return {
id,
name: (device ? computeDeviceName(device) : undefined) ?? id,
entityIds: ids.sort(sortByName),
};
})
.sort((a, b) => stringCompare(a.name, b.name, language));
const otherDomains: DomainGroup[] = [...orphanByDomain.entries()]
.map(([domain, ids]) => ({
domain,
name: domainToName(this.hass.localize, domain),
entityIds: ids.sort(sortByName),
}))
.sort((a, b) => stringCompare(a.name, b.name, language));
return {
floors: floorNodes,
unassignedAreas,
otherDevices,
otherDomains,
};
}
);
private _filteredTree = memoizeOne(
(tree: TreeData, filter: string, states: HomeAssistant["states"]) => {
if (!filter) return { tree, autoExpand: new Set<string>() };
const lower = filter.toLowerCase();
const matches = (entityId: string) => {
const stateObj = states[entityId];
const name = stateObj ? computeStateName(stateObj) : "";
return (
entityId.toLowerCase().includes(lower) ||
name.toLowerCase().includes(lower)
);
};
const autoExpand = new Set<string>();
const filterArea = (
area: AreaNode,
parentKey: string
): AreaNode | undefined => {
const aKey = areaKey(parentKey, area.id);
const directIds = area.directEntityIds.filter(matches);
const devices = area.devices
.map((device) => {
const ids = device.entityIds.filter(matches);
if (!ids.length) return undefined;
autoExpand.add(deviceKey(aKey, device.id));
return { ...device, entityIds: ids };
})
.filter((d): d is DeviceNode => !!d);
if (!directIds.length && !devices.length) return undefined;
autoExpand.add(aKey);
return { ...area, directEntityIds: directIds, devices };
};
const floors = tree.floors
.map((floor) => {
const fKey = floorKey(floor.id);
const areas = floor.areas
.map((a) => filterArea(a, fKey))
.filter((a): a is AreaNode => !!a);
if (!areas.length) return undefined;
autoExpand.add(fKey);
return { ...floor, areas };
})
.filter((f): f is FloorNode => !!f);
const noFloorKey = floorKey(NO_FLOOR_ID);
const unassignedAreas = tree.unassignedAreas
.map((a) => filterArea(a, noFloorKey))
.filter((a): a is AreaNode => !!a);
if (unassignedAreas.length) {
autoExpand.add(noFloorKey);
}
const otherKey = floorKey(OTHER_GROUP_ID);
const otherDevices = tree.otherDevices
.map((device) => {
const ids = device.entityIds.filter(matches);
if (!ids.length) return undefined;
autoExpand.add(deviceKey(otherKey, device.id));
return { ...device, entityIds: ids };
})
.filter((d): d is DeviceNode => !!d);
const otherDomains = tree.otherDomains
.map((group) => {
const ids = group.entityIds.filter(matches);
if (!ids.length) return undefined;
autoExpand.add(domainKey(otherKey, group.domain));
return { ...group, entityIds: ids };
})
.filter((g): g is DomainGroup => !!g);
if (otherDevices.length || otherDomains.length) {
autoExpand.add(otherKey);
}
return {
tree: { floors, unassignedAreas, otherDevices, otherDomains },
autoExpand,
};
}
);
protected render() {
if (!this.hass) return nothing;
const fullTree = this._buildTree(
this.hass.states,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors,
this.hass.locale?.language
);
const { tree, autoExpand } = this._filteredTree(
fullTree,
this._filter,
this.hass.states
);
const isExpanded = (key: string) =>
this._filter ? autoExpand.has(key) : (this._expanded[key] ?? false);
const noResults =
!tree.floors.length &&
!tree.unassignedAreas.length &&
!tree.otherDevices.length &&
!tree.otherDomains.length;
return html`
<ha-input-search
appearance="outlined"
.value=${this._filter}
.placeholder=${this.hass.localize(
"ui.panel.lovelace.editor.cardpicker.search_entities"
)}
@input=${this._handleFilterChange}
></ha-input-search>
<div class="tree ha-scrollbar">
${noResults
? html`<div class="empty">
${this.hass.localize("ui.common.no_results")}
</div>`
: nothing}
${tree.floors.map((floor) =>
this._renderFloor(floor, false, isExpanded)
)}
${tree.unassignedAreas.length
? this._renderFloor(
{
id: NO_FLOOR_ID,
name: this.hass.localize(
"ui.panel.lovelace.editor.cardpicker.no_floor"
),
icon: null,
level: null,
areas: tree.unassignedAreas,
},
true,
isExpanded
)
: nothing}
${tree.otherDevices.length || tree.otherDomains.length
? this._renderOther(tree.otherDevices, tree.otherDomains, isExpanded)
: nothing}
</div>
`;
}
private _renderChevron(expanded: boolean): TemplateResult {
return html`<ha-svg-icon
class="chevron"
.path=${expanded ? mdiChevronDown : mdiChevronRight}
></ha-svg-icon>`;
}
private _renderFloor(
floor: FloorNode,
isUnassigned: boolean,
isExpanded: (k: string) => boolean
): TemplateResult {
const key = floorKey(floor.id);
const expanded = isExpanded(key);
return html`
<ha-combo-box-item
type="button"
class="branch floor-item"
.nodeKey=${key}
@click=${this._toggleNode}
>
<div slot="start" class="leading">
${this._renderChevron(expanded)}
${isUnassigned
? html`<ha-svg-icon .path=${mdiTextureBox}></ha-svg-icon>`
: html`<ha-floor-icon
.floor=${{ icon: floor.icon, level: floor.level }}
></ha-floor-icon>`}
</div>
<span slot="headline">${floor.name}</span>
</ha-combo-box-item>
${expanded
? floor.areas.map((area) => this._renderArea(area, key, isExpanded))
: nothing}
`;
}
private _renderArea(
area: AreaNode,
parentKey: string,
isExpanded: (k: string) => boolean
): TemplateResult {
const key = areaKey(parentKey, area.id);
const expanded = isExpanded(key);
return html`
<ha-combo-box-item
type="button"
class="branch area-item"
style="--md-list-item-leading-space: ${LEAD_AREA};"
.nodeKey=${key}
@click=${this._toggleNode}
>
<div slot="start" class="leading">
${this._renderChevron(expanded)}
${area.icon
? html`<ha-icon .icon=${area.icon}></ha-icon>`
: html`<ha-svg-icon .path=${mdiTextureBox}></ha-svg-icon>`}
</div>
<span slot="headline">${area.name}</span>
</ha-combo-box-item>
${expanded
? html`
${area.directEntityIds.map((id) =>
this._renderEntity(id, LEAD_ENTITY_AREA)
)}
${area.devices.map((device) =>
this._renderDevice(device, key, isExpanded)
)}
`
: nothing}
`;
}
private _renderDevice(
device: DeviceNode,
parentKey: string,
isExpanded: (k: string) => boolean
): TemplateResult {
const key = deviceKey(parentKey, device.id);
const expanded = isExpanded(key);
const domain = this._deviceDomain(device.id);
return html`
<ha-combo-box-item
type="button"
class="branch device-item"
style="--md-list-item-leading-space: ${LEAD_DEVICE};"
.nodeKey=${key}
@click=${this._toggleNode}
>
<div slot="start" class="leading">
${this._renderChevron(expanded)}
${domain
? html`<ha-domain-icon
.hass=${this.hass}
.domain=${domain}
brand-fallback
></ha-domain-icon>`
: html`<ha-svg-icon .path=${mdiTextureBox}></ha-svg-icon>`}
</div>
<span slot="headline">${device.name}</span>
</ha-combo-box-item>
${expanded
? device.entityIds.map((id) =>
this._renderEntity(id, LEAD_ENTITY_DEVICE)
)
: nothing}
`;
}
private _renderOther(
devices: DeviceNode[],
domains: DomainGroup[],
isExpanded: (k: string) => boolean
): TemplateResult {
const key = floorKey(OTHER_GROUP_ID);
const expanded = isExpanded(key);
return html`
<ha-combo-box-item
type="button"
class="branch floor-item"
.nodeKey=${key}
@click=${this._toggleNode}
>
<div slot="start" class="leading">
${this._renderChevron(expanded)}
<ha-svg-icon .path=${mdiTextureBox}></ha-svg-icon>
</div>
<span slot="headline">
${this.hass.localize("ui.panel.lovelace.editor.cardpicker.no_area")}
</span>
</ha-combo-box-item>
${expanded
? html`
${devices.map((device) =>
this._renderDevice(device, key, isExpanded)
)}
${domains.map((g) => {
const dKey = domainKey(key, g.domain);
const dExpanded = isExpanded(dKey);
return html`
<ha-combo-box-item
type="button"
class="branch area-item"
style="--md-list-item-leading-space: ${LEAD_AREA};"
.nodeKey=${dKey}
@click=${this._toggleNode}
>
<div slot="start" class="leading">
${this._renderChevron(dExpanded)}
<ha-domain-icon
.hass=${this.hass}
.domain=${g.domain}
brand-fallback
></ha-domain-icon>
</div>
<span slot="headline">${g.name}</span>
</ha-combo-box-item>
${dExpanded
? g.entityIds.map((id) =>
this._renderEntity(id, LEAD_ENTITY_AREA)
)
: nothing}
`;
})}
`
: nothing}
`;
}
private _renderEntity(
entityId: string,
leadingSpace: string
): TemplateResult {
const stateObj = this.hass.states[entityId];
const selected = this.selectedEntityIds.includes(entityId);
const entityName = stateObj
? computeEntityName(stateObj, this.hass.entities, this.hass.devices)
: undefined;
const name =
entityName ||
(stateObj ? computeStateName(stateObj) : undefined) ||
entityId;
return html`
<ha-combo-box-item
type="button"
class="leaf entity-item ${selected ? "selected" : ""}"
style="--md-list-item-leading-space: ${leadingSpace};"
.entityId=${entityId}
@click=${this._toggleEntity}
>
<div slot="start" class="leading">
<span class="chevron-spacer"></span>
${stateObj
? html`<state-badge
.hass=${this.hass}
.stateObj=${stateObj}
></state-badge>`
: nothing}
</div>
<span slot="headline">${name}</span>
${selected
? html`<ha-svg-icon
slot="end"
class="check"
.path=${mdiCheck}
></ha-svg-icon>`
: nothing}
</ha-combo-box-item>
`;
}
private _toggleNode(ev: Event) {
const target = ev.currentTarget as HTMLElement & { nodeKey: string };
const key = target.nodeKey;
if (this._expanded[key]) {
const next = { ...this._expanded };
delete next[key];
const prefix = key + SEP;
for (const k of Object.keys(next)) {
if (k.startsWith(prefix)) delete next[k];
}
this._expanded = next;
} else {
this._expanded = { ...this._expanded, [key]: true };
}
}
private _toggleEntity(ev: Event) {
const target = ev.currentTarget as HTMLElement & { entityId: string };
fireEvent(this, "entity-toggled", { entityId: target.entityId });
}
private _handleFilterChange(ev: Event) {
this._filter = (ev.target as HaInputSearch).value ?? "";
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
display: flex;
flex-direction: column;
min-height: 0;
}
ha-input-search {
padding: var(--ha-space-3) var(--ha-space-3) var(--ha-space-2);
}
.tree {
flex: 1;
min-height: 0;
overflow: auto;
padding-bottom: var(--ha-space-3);
}
ha-combo-box-item {
--md-list-item-one-line-container-height: 40px;
--md-list-item-two-line-container-height: 48px;
--md-list-item-leading-space: var(--ha-space-3);
--md-list-item-trailing-space: var(--ha-space-3);
--md-list-item-leading-element-leading-space: 0;
width: 100%;
}
.leading {
display: flex;
align-items: center;
gap: var(--ha-space-2);
}
.leading ha-svg-icon,
.leading ha-icon,
.leading ha-floor-icon,
.leading ha-domain-icon {
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
}
.leading state-badge {
--state-icon-color: var(--secondary-text-color);
width: 20px;
height: 20px;
}
.chevron {
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
flex: 0 0 20px;
}
.chevron-spacer {
width: 20px;
flex: 0 0 20px;
}
.floor-item {
--md-list-item-label-text-weight: var(--ha-font-weight-medium);
}
.check {
color: var(--primary-color);
--mdc-icon-size: 20px;
}
.entity-item.selected {
background-color: var(
--ha-color-fill-primary-quiet-resting,
rgba(var(--rgb-primary-color, 33, 150, 243), 0.12)
);
--md-list-item-label-text-color: var(--primary-color);
--md-list-item-label-text-weight: var(--ha-font-weight-medium);
}
.entity-item.selected .leading state-badge {
--state-icon-color: var(--primary-color);
}
.empty {
padding: var(--ha-space-4);
color: var(--ha-color-text-secondary);
text-align: center;
font-size: var(--ha-font-size-s);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-recipe-entity-tree": HuiRecipeEntityTree;
}
}

View File

@@ -0,0 +1,448 @@
import { mdiClose, mdiViewGridPlus } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html, 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 { computeEntityPickerDisplay } from "../../../../common/entity/compute_entity_name_display";
import "../../../../components/entity/ha-entity-picker";
import "../../../../components/entity/state-badge";
import "../../../../components/ha-button";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-ripple";
import "../../../../components/ha-section-title";
import "../../../../components/ha-svg-icon";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import { haStyleScrollbar } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { CardSuggestion } from "../../card-suggestions/types";
import { generateCardSuggestions } from "../../common/card-suggestions";
import "./hui-recipe-entity-tree";
import "./hui-recipe-suggestion";
declare global {
interface HASSDomEvents {
"recipe-cards-picked": { cards: LovelaceCardConfig[] };
"recipe-browse-cards": undefined;
}
}
@customElement("hui-recipe-picker")
export class HuiRecipePicker extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false })
public sectionConfig?: LovelaceSectionConfig;
@property({ type: Array, attribute: false })
public prioritizedCardTypes?: string[];
@state() private _entityIds: string[] = [];
@state() private _narrow = false;
private _narrowMql?: MediaQueryList;
public connectedCallback(): void {
super.connectedCallback();
this._narrowMql = matchMedia("(max-width: 700px)");
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;
};
// Keyed on string args (not hass) so preview elements stay stable across
// hass updates.
private _computeSuggestions = memoizeOne(
(entityIdsKey: string, priorityTypesKey: string): CardSuggestion[] => {
const entityIds = entityIdsKey ? entityIdsKey.split("|") : [];
const priorityTypes = priorityTypesKey
? priorityTypesKey.split("|")
: undefined;
if (!this.hass) return [];
const suggestions = generateCardSuggestions(this.hass, entityIds);
if (!priorityTypes?.length) return suggestions;
const isPrioritized = (s: CardSuggestion) =>
priorityTypes.includes(s.config.type);
return [
...suggestions.filter(isPrioritized),
...suggestions.filter((s) => !isPrioritized(s)),
];
}
);
protected render() {
if (!this.hass) {
return nothing;
}
const suggestions = this._computeSuggestions(
this._entityIds.join("|"),
(this.prioritizedCardTypes ?? []).join("|")
);
return html`
<div class="sidebar">
<ha-section-title>
${this.hass.localize(
"ui.panel.lovelace.editor.cardpicker.selected_section"
)}
</ha-section-title>
<div class="selected-list-container">
${this._entityIds.length
? html`
<ha-md-list class="selected-list ha-scrollbar">
${repeat(
this._entityIds,
(id: string) => id,
(id: string) => this._renderEntityRow(id)
)}
</ha-md-list>
`
: html`
<div class="selected-empty">
${this.hass.localize(
"ui.panel.lovelace.editor.cardpicker.sidebar_empty"
)}
</div>
`}
</div>
<ha-section-title>
${this.hass.localize(
"ui.panel.lovelace.editor.cardpicker.browse_section"
)}
</ha-section-title>
${this._narrow
? html`
<div class="add-row">
<ha-entity-picker
.hass=${this.hass}
add-button
.excludeEntities=${this._entityIds}
@value-changed=${this._entityPickerValueChanged}
></ha-entity-picker>
</div>
`
: html`
<hui-recipe-entity-tree
class="tree"
.hass=${this.hass}
.selectedEntityIds=${this._entityIds}
@entity-toggled=${this._handleEntityToggled}
></hui-recipe-entity-tree>
`}
</div>
<div class="content ha-scrollbar">
${this._entityIds.length === 0
? html`
<div class="content-empty">
<h2>
${this.hass.localize(
"ui.panel.lovelace.editor.cardpicker.content_empty_title"
)}
</h2>
<p>
${this.hass.localize(
"ui.panel.lovelace.editor.cardpicker.content_empty_description"
)}
</p>
<ha-button appearance="plain" @click=${this._browseCards}>
<ha-svg-icon
slot="start"
.path=${mdiViewGridPlus}
></ha-svg-icon>
${this.hass.localize(
"ui.panel.lovelace.editor.cardpicker.browse_cards"
)}
</ha-button>
</div>
`
: html`
<div class="recipes" @suggestion-picked=${this._suggestionPicked}>
${repeat(
suggestions,
(s: CardSuggestion) => s.id,
(s: CardSuggestion) => html`
<hui-recipe-suggestion
.hass=${this.hass}
.suggestion=${s}
.sectionConfig=${this.sectionConfig}
></hui-recipe-suggestion>
`
)}
<div
class="browse-card"
tabindex="0"
role="button"
@click=${this._browseCards}
>
<ha-svg-icon .path=${mdiViewGridPlus}></ha-svg-icon>
<span class="browse-card-title">
${this.hass.localize(
"ui.panel.lovelace.editor.cardpicker.browse_cards"
)}
</span>
<p>
${this.hass.localize(
"ui.panel.lovelace.editor.cardpicker.not_found"
)}
</p>
<ha-ripple></ha-ripple>
</div>
</div>
`}
</div>
`;
}
private _renderEntityRow(entityId: string): TemplateResult {
const stateObj = this.hass!.states[entityId];
const display = stateObj
? computeEntityPickerDisplay(this.hass!, stateObj)
: { primary: entityId, secondary: undefined };
return html`
<ha-md-list-item type="text" class="entity-row">
${stateObj
? html`<state-badge
slot="start"
.hass=${this.hass}
.stateObj=${stateObj}
></state-badge>`
: nothing}
<div slot="headline">${display.primary}</div>
${display.secondary
? html`<div slot="supporting-text">${display.secondary}</div>`
: nothing}
<ha-icon-button
slot="end"
.path=${mdiClose}
.label=${this.hass!.localize("ui.common.remove")}
.entityId=${entityId}
@click=${this._removeEntity}
></ha-icon-button>
</ha-md-list-item>
`;
}
private _browseCards(): void {
fireEvent(this, "recipe-browse-cards", undefined);
}
private _handleEntityToggled(ev: CustomEvent<{ entityId: string }>): void {
const id = ev.detail.entityId;
if (this._entityIds.includes(id)) {
this._entityIds = this._entityIds.filter((e) => e !== id);
} else {
this._entityIds = [...this._entityIds, id];
}
}
private _entityPickerValueChanged(ev: CustomEvent<{ value: string }>): void {
const value = ev.detail.value;
if (!value || this._entityIds.includes(value)) {
return;
}
this._entityIds = [...this._entityIds, value];
}
private _removeEntity(ev: MouseEvent): void {
ev.stopPropagation();
const target = ev.currentTarget as HTMLElement & { entityId: string };
this._entityIds = this._entityIds.filter((id) => id !== target.entityId);
}
private _suggestionPicked(
ev: CustomEvent<{ suggestion: CardSuggestion }>
): void {
const { suggestion } = ev.detail;
const config = suggestion.config;
if (this.sectionConfig && suggestion.flattenInSection) {
const cards = config.cards as LovelaceCardConfig[] | undefined;
if (cards?.length) {
fireEvent(this, "recipe-cards-picked", { cards });
return;
}
}
fireEvent(this, "config-changed", { 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: 1px solid var(--divider-color);
min-height: 0;
overflow: hidden;
}
.selected-list-container {
flex: 0 0 30%;
min-height: 120px;
max-height: 240px;
display: flex;
flex-direction: column;
}
.selected-list {
padding: var(--ha-space-2) 0;
flex: 1;
overflow: auto;
}
.selected-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: var(--ha-space-4);
text-align: center;
color: var(--ha-color-text-secondary);
font-size: var(--ha-font-size-s);
line-height: var(--ha-line-height-expanded);
}
.tree {
flex: 1;
min-height: 0;
}
.add-row {
display: flex;
align-items: center;
padding: var(--ha-space-2) var(--ha-space-3);
}
.add-row ha-entity-picker {
flex: 1;
--ha-generic-picker-min-width: 0;
--ha-generic-picker-max-width: none;
}
.entity-row {
--md-list-item-leading-space: var(--ha-space-3);
--md-list-item-trailing-space: var(--ha-space-1);
--md-list-item-two-line-container-height: 48px;
--md-list-item-top-space: var(--ha-space-1);
--md-list-item-bottom-space: var(--ha-space-1);
}
.entity-row [slot="headline"],
.entity-row [slot="supporting-text"] {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.content {
flex: 1;
min-height: 0;
overflow: auto;
}
.recipes {
display: grid;
gap: var(--ha-space-3);
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
padding: var(--ha-space-3);
}
.content-empty {
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-card {
position: relative;
box-sizing: border-box;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--ha-space-2);
padding: var(--ha-space-6);
text-align: center;
cursor: pointer;
overflow: hidden;
border-radius: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
);
border: var(--ha-card-border-width, 1px) dashed
var(--ha-card-border-color, var(--divider-color));
background: var(--primary-background-color, #fafafa);
color: var(--primary-text-color);
}
.browse-card ha-svg-icon {
color: var(--ha-color-text-secondary);
}
.browse-card-title {
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
}
.browse-card p {
margin: 0;
color: var(--ha-color-text-secondary);
font-size: var(--ha-font-size-s);
}
@media (max-width: 700px) {
:host {
flex-direction: column;
overflow-y: auto;
}
.sidebar,
.content {
flex: 0 0 auto;
overflow: visible;
}
.sidebar {
border-inline-end: none;
border-bottom: 1px solid var(--divider-color);
}
.selected-list {
max-height: none;
}
.tree {
min-height: 320px;
}
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-recipe-picker": HuiRecipePicker;
}
}

View File

@@ -0,0 +1,194 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-ripple";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import type { HomeAssistant } from "../../../../types";
import "../../cards/hui-card";
import type { CardSuggestion } from "../../card-suggestions/types";
import "../../sections/hui-section";
declare global {
interface HASSDomEvents {
"suggestion-picked": { suggestion: CardSuggestion };
}
}
const PREVIEW_ITEM_CAP = 6;
const getItemCount = (config: LovelaceCardConfig): number => {
const c = config as { cards?: unknown[]; entities?: unknown[] };
return c.cards?.length ?? c.entities?.length ?? 0;
};
const cappedCardConfig = (config: LovelaceCardConfig): LovelaceCardConfig => {
const c = config as { cards?: LovelaceCardConfig[]; entities?: unknown[] };
if (c.cards && c.cards.length > PREVIEW_ITEM_CAP) {
return { ...config, cards: c.cards.slice(0, PREVIEW_ITEM_CAP) };
}
if (c.entities && c.entities.length > PREVIEW_ITEM_CAP) {
return { ...config, entities: c.entities.slice(0, PREVIEW_ITEM_CAP) };
}
return config;
};
type PreviewConfig =
| { section: LovelaceSectionConfig }
| { card: LovelaceCardConfig };
@customElement("hui-recipe-suggestion")
export class HuiRecipeSuggestion extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public suggestion!: CardSuggestion;
@property({ attribute: false }) public sectionConfig?: LovelaceSectionConfig;
@state() private _preview?: PreviewConfig;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (!changedProps.has("suggestion") && !changedProps.has("sectionConfig")) {
return;
}
this._preview = this._computePreview();
}
private _computePreview(): PreviewConfig | undefined {
const { suggestion, sectionConfig } = this;
if (!suggestion?.config) return undefined;
if (suggestion.flattenInSection && sectionConfig) {
const cards = suggestion.config.cards as LovelaceCardConfig[] | undefined;
if (cards?.length) {
return {
section: {
...sectionConfig,
cards: cards.slice(0, PREVIEW_ITEM_CAP),
},
};
}
}
return { card: cappedCardConfig(suggestion.config) };
}
private _renderPreview(): TemplateResult | typeof nothing {
if (!this._preview) return nothing;
if ("section" in this._preview) {
return html`
<hui-section
.hass=${this.hass}
.config=${this._preview.section}
.viewIndex=${0}
.index=${0}
preview
></hui-section>
`;
}
return html`
<hui-card
.hass=${this.hass}
.config=${this._preview.card}
preview
></hui-card>
`;
}
protected render(): TemplateResult {
const { suggestion } = this;
const totalCount = getItemCount(suggestion.config);
const hiddenCount = Math.max(0, totalCount - PREVIEW_ITEM_CAP);
return html`
<div class="card" tabindex="0">
<div
class="overlay"
role="button"
aria-label=${suggestion.label}
@click=${this._handleClick}
></div>
<div class="card-header">${suggestion.label}</div>
<div class="preview">${this._renderPreview()}</div>
${hiddenCount > 0
? html`
<div class="more-badge">
${this.hass?.localize(
"ui.panel.lovelace.editor.cardpicker.more_cards",
{ count: hiddenCount }
)}
</div>
`
: nothing}
<ha-ripple></ha-ripple>
</div>
`;
}
private _handleClick(): void {
fireEvent(this, "suggestion-picked", { suggestion: this.suggestion });
}
static readonly styles: CSSResultGroup = css`
:host {
display: block;
height: 100%;
}
.card {
height: 100%;
display: flex;
flex-direction: column;
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
background: var(--primary-background-color, #fafafa);
cursor: pointer;
position: relative;
overflow: hidden;
border: var(--ha-card-border-width, 1px) solid
var(--ha-card-border-color, var(--divider-color));
}
.card-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;
}
.preview > :first-child {
display: block;
width: 100%;
}
.overlay {
position: absolute;
width: 100%;
height: 100%;
z-index: 1;
box-sizing: border-box;
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
}
.more-badge {
margin: 0 var(--ha-space-4) var(--ha-space-3);
padding: var(--ha-space-1) var(--ha-space-2);
align-self: flex-start;
border-radius: var(--ha-border-radius-md);
background: var(--ha-color-fill-neutral-quiet-resting);
color: var(--ha-color-text-secondary);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-recipe-suggestion": HuiRecipeSuggestion;
}
}

View File

@@ -24,121 +24,20 @@ import {
stripCustomPrefix,
} from "../../../../data/lovelace_custom_cards";
import type { HomeAssistant } from "../../../../types";
import { supportsAlarmModesCardFeature } from "../../card-features/hui-alarm-modes-card-feature";
import { supportsAreaControlsCardFeature } from "../../card-features/hui-area-controls-card-feature";
import { supportsBarGaugeCardFeature } from "../../card-features/hui-bar-gauge-card-feature";
import { supportsButtonCardFeature } from "../../card-features/hui-button-card-feature";
import { supportsClimateFanModesCardFeature } from "../../card-features/hui-climate-fan-modes-card-feature";
import { supportsClimateHvacModesCardFeature } from "../../card-features/hui-climate-hvac-modes-card-feature";
import { supportsClimatePresetModesCardFeature } from "../../card-features/hui-climate-preset-modes-card-feature";
import { supportsClimateSwingHorizontalModesCardFeature } from "../../card-features/hui-climate-swing-horizontal-modes-card-feature";
import { supportsClimateSwingModesCardFeature } from "../../card-features/hui-climate-swing-modes-card-feature";
import { supportsCounterActionsCardFeature } from "../../card-features/hui-counter-actions-card-feature";
import { supportsCoverOpenCloseCardFeature } from "../../card-features/hui-cover-open-close-card-feature";
import { supportsCoverPositionFavoriteCardFeature } from "../../card-features/hui-cover-position-favorite-card-feature";
import { supportsCoverPositionCardFeature } from "../../card-features/hui-cover-position-card-feature";
import { supportsCoverTiltCardFeature } from "../../card-features/hui-cover-tilt-card-feature";
import { supportsCoverTiltFavoriteCardFeature } from "../../card-features/hui-cover-tilt-favorite-card-feature";
import { supportsCoverTiltPositionCardFeature } from "../../card-features/hui-cover-tilt-position-card-feature";
import { supportsDailyForecastCardFeature } from "../../card-features/hui-daily-forecast-card-feature";
import { supportsDateSetCardFeature } from "../../card-features/hui-date-set-card-feature";
import { supportsFanDirectionCardFeature } from "../../card-features/hui-fan-direction-card-feature";
import { supportsHourlyForecastCardFeature } from "../../card-features/hui-hourly-forecast-card-feature";
import { supportsFanOscilatteCardFeature } from "../../card-features/hui-fan-oscillate-card-feature";
import { supportsFanPresetModesCardFeature } from "../../card-features/hui-fan-preset-modes-card-feature";
import { supportsFanSpeedCardFeature } from "../../card-features/hui-fan-speed-card-feature";
import { supportsHumidifierModesCardFeature } from "../../card-features/hui-humidifier-modes-card-feature";
import { supportsHumidifierToggleCardFeature } from "../../card-features/hui-humidifier-toggle-card-feature";
import { supportsLawnMowerCommandCardFeature } from "../../card-features/hui-lawn-mower-commands-card-feature";
import { supportsLightBrightnessCardFeature } from "../../card-features/hui-light-brightness-card-feature";
import { supportsLightColorTempCardFeature } from "../../card-features/hui-light-color-temp-card-feature";
import { supportsLockCommandsCardFeature } from "../../card-features/hui-lock-commands-card-feature";
import { supportsLockOpenDoorCardFeature } from "../../card-features/hui-lock-open-door-card-feature";
import { supportsMediaPlayerPlaybackCardFeature } from "../../card-features/hui-media-player-playback-card-feature";
import { supportsMediaPlayerSoundModeCardFeature } from "../../card-features/hui-media-player-sound-mode-card-feature";
import { supportsMediaPlayerSourceCardFeature } from "../../card-features/hui-media-player-source-card-feature";
import { supportsMediaPlayerVolumeButtonsCardFeature } from "../../card-features/hui-media-player-volume-buttons-card-feature";
import { supportsMediaPlayerVolumeSliderCardFeature } from "../../card-features/hui-media-player-volume-slider-card-feature";
import { supportsNumericInputCardFeature } from "../../card-features/hui-numeric-input-card-feature";
import { supportsSelectOptionsCardFeature } from "../../card-features/hui-select-options-card-feature";
import { supportsTargetHumidityCardFeature } from "../../card-features/hui-target-humidity-card-feature";
import { supportsTargetTemperatureCardFeature } from "../../card-features/hui-target-temperature-card-feature";
import { supportsToggleCardFeature } from "../../card-features/hui-toggle-card-feature";
import { supportsTrendGraphCardFeature } from "../../card-features/hui-trend-graph-card-feature";
import { supportsUpdateActionsCardFeature } from "../../card-features/hui-update-actions-card-feature";
import { supportsVacuumCommandsCardFeature } from "../../card-features/hui-vacuum-commands-card-feature";
import { supportsValveOpenCloseCardFeature } from "../../card-features/hui-valve-open-close-card-feature";
import { supportsValvePositionFavoriteCardFeature } from "../../card-features/hui-valve-position-favorite-card-feature";
import { supportsValvePositionCardFeature } from "../../card-features/hui-valve-position-card-feature";
import { supportsWaterHeaterOperationModesCardFeature } from "../../card-features/hui-water-heater-operation-modes-card-feature";
import type {
LovelaceCardFeatureConfig,
LovelaceCardFeatureContext,
} from "../../card-features/types";
import type { UiFeatureType } from "../../card-features/registry";
import {
SUPPORTS_FEATURE_TYPES,
UI_FEATURE_TYPES,
} from "../../card-features/registry";
import { getCardFeatureElementClass } from "../../create-element/create-card-feature-element";
import { supportsLightColorFavoritesCardFeature } from "../../card-features/hui-light-color-favorites-card-feature";
export type FeatureType = LovelaceCardFeatureConfig["type"];
type SupportsFeature = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext
) => boolean;
const UI_FEATURE_TYPES = [
"alarm-modes",
"area-controls",
"bar-gauge",
"button",
"climate-fan-modes",
"climate-hvac-modes",
"climate-preset-modes",
"climate-swing-modes",
"climate-swing-horizontal-modes",
"counter-actions",
"cover-open-close",
"cover-position-favorite",
"cover-position",
"cover-tilt-favorite",
"cover-tilt-position",
"cover-tilt",
"daily-forecast",
"date-set",
"fan-direction",
"fan-oscillate",
"fan-preset-modes",
"fan-speed",
"hourly-forecast",
"humidifier-modes",
"humidifier-toggle",
"lawn-mower-commands",
"light-brightness",
"light-color-temp",
"light-color-favorites",
"lock-commands",
"lock-open-door",
"media-player-playback",
"media-player-sound-mode",
"media-player-source",
"media-player-volume-buttons",
"media-player-volume-slider",
"numeric-input",
"select-options",
"trend-graph",
"target-humidity",
"target-temperature",
"toggle",
"update-actions",
"vacuum-commands",
"valve-open-close",
"valve-position-favorite",
"valve-position",
"water-heater-operation-modes",
] as const satisfies readonly FeatureType[];
type UiFeatureTypes = (typeof UI_FEATURE_TYPES)[number];
const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
const EDITABLES_FEATURE_TYPES = new Set<UiFeatureType>([
"alarm-modes",
"area-controls",
"bar-gauge",
@@ -168,64 +67,6 @@ const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
"water-heater-operation-modes",
]);
const SUPPORTS_FEATURE_TYPES: Record<
UiFeatureTypes,
SupportsFeature | undefined
> = {
"alarm-modes": supportsAlarmModesCardFeature,
"area-controls": supportsAreaControlsCardFeature,
"bar-gauge": supportsBarGaugeCardFeature,
button: supportsButtonCardFeature,
"climate-fan-modes": supportsClimateFanModesCardFeature,
"climate-swing-modes": supportsClimateSwingModesCardFeature,
"climate-swing-horizontal-modes":
supportsClimateSwingHorizontalModesCardFeature,
"climate-hvac-modes": supportsClimateHvacModesCardFeature,
"climate-preset-modes": supportsClimatePresetModesCardFeature,
"counter-actions": supportsCounterActionsCardFeature,
"cover-open-close": supportsCoverOpenCloseCardFeature,
"cover-position-favorite": supportsCoverPositionFavoriteCardFeature,
"cover-position": supportsCoverPositionCardFeature,
"cover-tilt-favorite": supportsCoverTiltFavoriteCardFeature,
"cover-tilt-position": supportsCoverTiltPositionCardFeature,
"cover-tilt": supportsCoverTiltCardFeature,
"daily-forecast": (hass, context) =>
supportsDailyForecastCardFeature(
context.entity_id ? hass.states[context.entity_id] : undefined
),
"date-set": supportsDateSetCardFeature,
"fan-direction": supportsFanDirectionCardFeature,
"fan-oscillate": supportsFanOscilatteCardFeature,
"fan-preset-modes": supportsFanPresetModesCardFeature,
"fan-speed": supportsFanSpeedCardFeature,
"hourly-forecast": supportsHourlyForecastCardFeature,
"humidifier-modes": supportsHumidifierModesCardFeature,
"humidifier-toggle": supportsHumidifierToggleCardFeature,
"lawn-mower-commands": supportsLawnMowerCommandCardFeature,
"light-brightness": supportsLightBrightnessCardFeature,
"light-color-temp": supportsLightColorTempCardFeature,
"light-color-favorites": supportsLightColorFavoritesCardFeature,
"lock-commands": supportsLockCommandsCardFeature,
"lock-open-door": supportsLockOpenDoorCardFeature,
"media-player-playback": supportsMediaPlayerPlaybackCardFeature,
"media-player-sound-mode": supportsMediaPlayerSoundModeCardFeature,
"media-player-source": supportsMediaPlayerSourceCardFeature,
"media-player-volume-buttons": supportsMediaPlayerVolumeButtonsCardFeature,
"media-player-volume-slider": supportsMediaPlayerVolumeSliderCardFeature,
"numeric-input": supportsNumericInputCardFeature,
"select-options": supportsSelectOptionsCardFeature,
"trend-graph": supportsTrendGraphCardFeature,
"target-humidity": supportsTargetHumidityCardFeature,
"target-temperature": supportsTargetTemperatureCardFeature,
toggle: supportsToggleCardFeature,
"update-actions": supportsUpdateActionsCardFeature,
"vacuum-commands": supportsVacuumCommandsCardFeature,
"valve-open-close": supportsValveOpenCloseCardFeature,
"valve-position-favorite": supportsValvePositionFavoriteCardFeature,
"valve-position": supportsValvePositionCardFeature,
"water-heater-operation-modes": supportsWaterHeaterOperationModesCardFeature,
};
const customCardFeatures = getCustomCardFeatures();
const CUSTOM_FEATURE_ENTRIES: Record<
@@ -283,7 +124,7 @@ export const supportsFeaturesType = (
}
}
const supportsFeature = SUPPORTS_FEATURE_TYPES[type];
const supportsFeature = SUPPORTS_FEATURE_TYPES[type as UiFeatureType];
return !supportsFeature || supportsFeature(hass, context);
};

View File

@@ -10122,7 +10122,55 @@
"domain": "Domain",
"entity": "Entity",
"by_entity": "By entity",
"by_card": "By card"
"by_card": "By card",
"empty_title": "What do you want to control?",
"empty_description": "Pick an entity to see suggested cards, or browse all card types.",
"sidebar_title": "What do you want to add?",
"selected_section": "Selected",
"sidebar_empty": "Pick entities to build your card.",
"browse_section": "Add entities",
"search_entities": "Search entities",
"no_floor": "Without a floor",
"no_area": "Without an area",
"browse_cards": "Browse all cards",
"more_cards": "+{count} more",
"content_empty_title": "Add an entity to see suggestions",
"content_empty_description": "Pick an entity from the sidebar, or browse all card types.",
"not_found": "Can't find the card you want?",
"suggestions": {
"tile": "Tile",
"tile_toggle": "Tile (toggle)",
"tile_brightness": "Tile (brightness)",
"tile_color_temperature": "Tile (color temperature)",
"tile_favorite_colors": "Tile (favorite colors)",
"tile_open_close": "Tile (open/close)",
"tile_position": "Tile (position)",
"tile_tilt": "Tile (tilt)",
"tile_hvac_modes": "Tile (HVAC modes)",
"tile_playback_controls": "Tile (playback controls)",
"tile_volume_slider": "Tile (volume slider)",
"tile_speed": "Tile (speed)",
"tile_preset_modes": "Tile (preset modes)",
"tile_options": "Tile (options)",
"tile_numeric_input": "Tile (numeric input)",
"tile_date_picker": "Tile (date picker)",
"tile_trend_graph": "Tile (trend graph)",
"tile_lock_commands": "Tile (lock commands)",
"tile_humidifier_toggle": "Tile (humidifier toggle)",
"tile_humidifier_modes": "Tile (humidifier modes)",
"tile_vacuum_commands": "Tile (vacuum commands)",
"tile_mower_commands": "Tile (mower commands)",
"tile_alarm_modes": "Tile (alarm modes)",
"tile_counter_actions": "Tile (counter actions)",
"tile_update_actions": "Tile (update actions)",
"tile_operation_modes": "Tile (operation modes)",
"tile_hourly_forecast": "Tile (hourly forecast)",
"tile_cards": "Tile cards",
"featured_tile_cards": "Featured tile cards",
"entities_card": "Entities card",
"calendar": "Calendar",
"todo_list": "To-do list"
}
},
"badge_picker": {
"no_description": "No description available.",

View File

@@ -0,0 +1,143 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { afterEach, describe, expect, it } from "vitest";
import { CARD_SUGGESTION_PROVIDERS } from "../../../../src/panels/lovelace/card-suggestions/registry";
import type { CardSuggestionProvider } from "../../../../src/panels/lovelace/card-suggestions/types";
import { generateCardSuggestions } from "../../../../src/panels/lovelace/common/card-suggestions";
import type { HomeAssistant } from "../../../../src/types";
const makeState = (
entityId: string,
state = "on",
attributes: Record<string, unknown> = {}
): HassEntity => ({
entity_id: entityId,
state,
attributes,
last_changed: "",
last_updated: "",
context: { id: "", parent_id: null, user_id: null },
});
const makeHass = (states: HassEntity[]): HomeAssistant =>
({
states: Object.fromEntries(states.map((s) => [s.entity_id, s])),
localize: (key: string) => key,
}) as unknown as HomeAssistant;
const registerTestProviders = (
providers: Record<string, CardSuggestionProvider>
): (() => void) => {
const keys = Object.keys(providers);
for (const key of keys) {
CARD_SUGGESTION_PROVIDERS[key] = providers[key];
}
return () => {
for (const key of keys) {
delete CARD_SUGGESTION_PROVIDERS[key];
}
};
};
describe("generateCardSuggestions", () => {
let cleanupProviders: (() => void) | undefined;
afterEach(() => {
cleanupProviders?.();
cleanupProviders = undefined;
});
it("suggests nothing when no entities are picked", () => {
expect(generateCardSuggestions(makeHass([]), [])).toEqual([]);
});
it("suggests nothing when the picked entity doesn't exist", () => {
expect(generateCardSuggestions(makeHass([]), ["light.ghost"])).toEqual([]);
});
it("ignores unknown entities when picking multiple", () => {
const hass = makeHass([
makeState("light.a", "on", { supported_color_modes: ["onoff"] }),
]);
// "light.ghost" has no state — only light.a remains, single-entity path.
const suggestions = generateCardSuggestions(hass, [
"light.a",
"light.ghost",
]);
expect(suggestions.some((s) => s.id === "tile")).toBe(true);
expect(suggestions.every((s) => s.id !== "tile-cards")).toBe(true);
});
it("suggests a grid and an entities card when picking multiple entities", () => {
const hass = makeHass([
makeState("light.a", "on"),
makeState("light.b", "on"),
makeState("light.c", "on"),
]);
const suggestions = generateCardSuggestions(hass, [
"light.a",
"light.b",
"light.c",
]);
expect(suggestions.map((s) => s.id)).toEqual([
"tile-cards",
"tile-cards-with-features",
"entities-card",
]);
expect(suggestions[0].config.type).toBe("grid");
expect(suggestions[suggestions.length - 1].config).toEqual({
type: "entities",
entities: ["light.a", "light.b", "light.c"],
});
});
it("accepts null, a single suggestion, or a list from each provider", () => {
cleanupProviders = registerTestProviders({
"test-null": { getEntitySuggestion: () => null },
"test-single": {
getEntitySuggestion: (_hass, entityId) => ({
id: "single",
label: "Single",
config: { type: "custom:test-single", entity: entityId },
}),
},
"test-array": {
getEntitySuggestion: (_hass, entityId) => [
{
id: "array-a",
label: "Array A",
config: { type: "custom:test-array", entity: entityId },
},
{
id: "array-b",
label: "Array B",
config: { type: "custom:test-array", entity: entityId },
},
],
},
});
const hass = makeHass([makeState("sensor.a", "1")]);
const ids = generateCardSuggestions(hass, ["sensor.a"]).map((s) => s.id);
expect(ids).toContain("single");
expect(ids).toContain("array-a");
expect(ids).toContain("array-b");
});
it("keeps working when a provider throws", () => {
cleanupProviders = registerTestProviders({
"test-throws": {
getEntitySuggestion: () => {
throw new Error("boom");
},
},
});
const hass = makeHass([makeState("sensor.a", "1")]);
const ids = generateCardSuggestions(hass, ["sensor.a"]).map((s) => s.id);
// Tile still provides its default suggestion for sensor.
expect(ids).toContain("tile");
});
});

View File

@@ -1,9 +1,5 @@
import { beforeAll } from "vitest";
global.window = (global.window ?? {}) as any;
global.navigator = (global.navigator ?? {}) as any;
beforeAll(() => {
global.window = {} as any;
global.navigator = {} as any;
global.__DEMO__ = false;
global.__DEV__ = false;
});
global.__DEMO__ = false;
global.__DEV__ = false;