diff --git a/src/components/ha-navigation-picker.ts b/src/components/ha-navigation-picker.ts index 0b9288952d..e2cc9e164f 100644 --- a/src/components/ha-navigation-picker.ts +++ b/src/components/ha-navigation-picker.ts @@ -1,14 +1,13 @@ import Fuse from "fuse.js"; -import { mdiDevices, mdiTextureBox } from "@mdi/js"; import { html, LitElement, nothing, type PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { fireEvent } from "../common/dom/fire_event"; import { caseInsensitiveStringCompare } from "../common/string/compare"; -import { titleCase } from "../common/string/title-case"; import { getConfigEntries, type ConfigEntry } from "../data/config_entries"; import { fetchConfig } from "../data/lovelace/config/types"; -import { getPanelIcon, getPanelTitle, SYSTEM_PANELS } from "../data/panel"; +import { SYSTEM_PANELS } from "../data/panel"; +import { computeNavigationPathInfo } from "../data/compute-navigation-path-info"; import { findRelated, type RelatedResult } from "../data/search"; import { PANEL_DASHBOARDS } from "../panels/config/lovelace/dashboards/ha-config-lovelace-dashboards"; import { computeAreaPath } from "../panels/lovelace/strategies/areas/helpers/areas-strategy-helper"; @@ -31,6 +30,12 @@ const RELATED_SORT_PREFIX = { device: "2_device", } as const; +const createSortingLabel = (...parts: (string | undefined)[]) => + parts + .filter(Boolean) + .map((part) => (part!.startsWith("/") ? `zzz${part}` : part)) + .join("_"); + interface NavigationItem extends PickerComboBoxItem { group: NavigationGroup; domain?: string; @@ -276,22 +281,16 @@ export class HaNavigationPicker extends LitElement { for (const panel of panels) { if (SYSTEM_PANELS.includes(panel.id)) continue; const path = `/${panel.url_path}`; - const panelTitle = getPanelTitle(this.hass, panel); - const primary = panelTitle || path; + const resolved = computeNavigationPathInfo(this.hass!, path); const isDashboardPanel = panel.component_name === "lovelace" || PANEL_DASHBOARDS.includes(panel.id); const panelItem: NavigationItem = { id: path, - primary, - secondary: panelTitle ? path : undefined, - icon: getPanelIcon(panel) || "mdi:view-dashboard", - sorting_label: [ - primary.startsWith("/") ? `zzz${primary}` : primary, - path, - ] - .filter(Boolean) - .join("_"), + primary: resolved.label, + secondary: resolved.label !== path ? path : undefined, + icon: resolved.icon || "mdi:view-dashboard", + sorting_label: createSortingLabel(resolved.label, path), group: isDashboardPanel ? "dashboards" : "other_routes", }; @@ -307,17 +306,17 @@ export class HaNavigationPicker extends LitElement { config.views.forEach((view, index) => { const viewPath = `/${panel.url_path}/${view.path ?? index}`; - const viewPrimary = - view.title ?? (view.path ? titleCase(view.path) : `${index}`); + const viewInfo = computeNavigationPathInfo( + this.hass!, + viewPath, + config + ); views.push({ id: viewPath, secondary: viewPath, - icon: view.icon ?? "mdi:view-compact", - primary: viewPrimary, - sorting_label: [ - viewPrimary.startsWith("/") ? `zzz${viewPrimary}` : viewPrimary, - viewPath, - ].join("_"), + icon: viewInfo.icon || "mdi:view-compact", + primary: viewInfo.label, + sorting_label: createSortingLabel(viewInfo.label, viewPath), group: "views", }); }); @@ -399,28 +398,20 @@ export class HaNavigationPicker extends LitElement { relatedAreaIds.add(context.area_id); } - const createSortingLabel = ( - prefix: string, - primary: string, - path: string - ) => - [prefix, primary.startsWith("/") ? `zzz${primary}` : primary, path] - .filter(Boolean) - .join("_"); - const relatedItems: NavigationItem[] = []; for (const deviceId of relatedDeviceIds) { const device = this.hass.devices[deviceId]; - const primary = device?.name_by_user ?? device?.name ?? deviceId; const path = `/config/devices/device/${deviceId}`; + const resolved = computeNavigationPathInfo(this.hass, path); relatedItems.push({ id: path, - primary, + primary: resolved.label, secondary: path, - icon_path: mdiDevices, + icon: resolved.icon, + icon_path: resolved.iconPath, sorting_label: createSortingLabel( RELATED_SORT_PREFIX.device, - primary, + resolved.label, path ), group: "related", @@ -431,20 +422,18 @@ export class HaNavigationPicker extends LitElement { } for (const areaId of relatedAreaIds) { - const area = this.hass.areas[areaId]; - const primary = area?.name ?? areaId; - // Area dashboard view const viewPath = `/home/${computeAreaPath(areaId)}`; + const resolvedArea = computeNavigationPathInfo(this.hass, viewPath); relatedItems.push({ id: viewPath, - primary, + primary: resolvedArea.label, secondary: viewPath, - icon: area?.icon ?? undefined, - icon_path: area?.icon ? undefined : mdiTextureBox, + icon: resolvedArea.icon, + icon_path: resolvedArea.icon ? undefined : resolvedArea.iconPath, sorting_label: createSortingLabel( RELATED_SORT_PREFIX.area_view, - primary, + resolvedArea.label, viewPath ), group: "related", @@ -456,14 +445,14 @@ export class HaNavigationPicker extends LitElement { id: path, primary: this.hass.localize( "ui.components.navigation-picker.area_settings", - { area: primary } + { area: resolvedArea.label } ), secondary: path, - icon: area?.icon ?? undefined, - icon_path: area?.icon ? undefined : mdiTextureBox, + icon: resolvedArea.icon, + icon_path: resolvedArea.icon ? undefined : resolvedArea.iconPath, sorting_label: createSortingLabel( RELATED_SORT_PREFIX.area, - primary, + resolvedArea.label, path ), group: "related", diff --git a/src/components/ha-selector/ha-selector-text.ts b/src/components/ha-selector/ha-selector-text.ts index 1e0e2f188c..97125796dd 100644 --- a/src/components/ha-selector/ha-selector-text.ts +++ b/src/components/ha-selector/ha-selector-text.ts @@ -78,7 +78,7 @@ export class HaTextSelector extends LitElement { return html` { + const segments = path.replace(/^\//, "").split(/[/?]/); + const panelUrlPath = segments[0]; + const subPath = segments[1]; + + // /config/areas/area/{areaId} + if ( + panelUrlPath === "config" && + segments[1] === "areas" && + segments[2] === "area" && + segments[3] + ) { + return computeAreaNavigationPathInfo(hass, segments[3]); + } + + // /config/devices/device/{deviceId} + if ( + panelUrlPath === "config" && + segments[1] === "devices" && + segments[2] === "device" && + segments[3] + ) { + return computeDeviceNavigationPathInfo(hass, segments[3]); + } + + const panel = panelUrlPath ? hass.panels[panelUrlPath] : undefined; + const panelIcon = panel ? getPanelIcon(panel) : undefined; + const panelIconPath = panel ? getPanelIconPath(panel) : undefined; + + // /home/areas-{areaId} (area dashboard view) + if (subPath?.startsWith(AREA_VIEW_PREFIX)) { + const areaId = subPath.slice(AREA_VIEW_PREFIX.length); + return computeAreaNavigationPathInfo(hass, areaId); + } + + const isDashboard = panel?.component_name === "lovelace"; + + const panelInfo: NavigationPathInfo = { + label: getPanelTitleFromUrlPath(hass, panelUrlPath) || panelUrlPath, + icon: panelIcon || (isDashboard ? "mdi:view-dashboard" : undefined), + iconPath: panelIconPath || mdiLink, + }; + + // Lovelace view path + if (subPath && lovelaceConfig && "views" in lovelaceConfig) { + const viewIndex = lovelaceConfig.views.findIndex( + (v, index) => (v.path ?? String(index)) === subPath + ); + if (viewIndex !== -1) { + const view = lovelaceConfig.views[viewIndex]; + return { + ...panelInfo, + label: computeViewTitle(view, viewIndex), + icon: computeViewIcon(view), + }; + } + } + + return panelInfo; +}; + +const computeAreaNavigationPathInfo = ( + hass: HomeAssistant, + areaId: string +): NavigationPathInfo => { + const area = hass.areas[areaId]; + return { + label: area?.name || areaId, + icon: area?.icon || undefined, + iconPath: mdiTextureBox, + }; +}; + +const computeDeviceNavigationPathInfo = ( + hass: HomeAssistant, + deviceId: string +): NavigationPathInfo => { + const device = hass.devices[deviceId]; + return { + label: (device ? computeDeviceName(device) : undefined) || deviceId, + iconPath: mdiDevices, + }; +}; + +/** + * Subscribe to navigation path info updates. + * Resolves synchronously first, then subscribes to lovelace config + * updates for view paths. + */ +export const subscribeNavigationPathInfo = ( + hass: HomeAssistant, + path: string, + onChange: (info: NavigationPathInfo) => void +): UnsubscribeFunc | undefined => { + const info = computeNavigationPathInfo(hass, path); + onChange(info); + + const segments = path.replace(/^\//, "").split(/[/?]/); + const panelUrlPath = segments[0]; + const panel = panelUrlPath ? hass.panels[panelUrlPath] : undefined; + + if (segments[1] && panel?.component_name === "lovelace") { + let current = info; + const collection = getLovelaceCollection(hass.connection, panelUrlPath); + return collection.subscribe((config) => { + const newInfo = computeNavigationPathInfo(hass, path, config); + if (newInfo.label !== current.label || newInfo.icon !== current.icon) { + current = newInfo; + onChange(newInfo); + } + }); + } + + return undefined; +}; diff --git a/src/data/lovelace/config/view.ts b/src/data/lovelace/config/view.ts index 0c82d4755b..dce86ab0ca 100644 --- a/src/data/lovelace/config/view.ts +++ b/src/data/lovelace/config/view.ts @@ -1,3 +1,4 @@ +import { titleCase } from "../../../common/string/title-case"; import type { Condition } from "../../../panels/lovelace/common/validate-condition"; import type { MediaSelectorValue } from "../../selector"; import type { LovelaceBadgeConfig } from "./badge"; @@ -93,3 +94,11 @@ export function isStrategyView( ): view is LovelaceStrategyViewConfig { return "strategy" in view; } + +export const computeViewTitle = ( + view: LovelaceBaseViewConfig, + index: number +): string => view.title ?? (view.path ? titleCase(view.path) : String(index)); + +export const computeViewIcon = (view: LovelaceBaseViewConfig): string => + view.icon ?? "mdi:view-compact"; diff --git a/src/data/selector.ts b/src/data/selector.ts index 773ee8ad7b..a564c898b0 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -494,6 +494,7 @@ export interface StringSelector { | "color"; prefix?: string; suffix?: string; + placeholder?: string; autocomplete?: string; multiple?: true; } | null; diff --git a/src/panels/lovelace/cards/hui-shortcut-card-defaults.ts b/src/panels/lovelace/cards/hui-shortcut-card-defaults.ts new file mode 100644 index 0000000000..3e6d56e23c --- /dev/null +++ b/src/panels/lovelace/cards/hui-shortcut-card-defaults.ts @@ -0,0 +1,37 @@ +import { mdiLink, mdiMicrophone, mdiOpenInNew, mdiRoomService } from "@mdi/js"; +import type { NavigationPathInfo } from "../../../data/compute-navigation-path-info"; +import type { ActionConfig } from "../../../data/lovelace/config/action"; +import type { HomeAssistant } from "../../../types"; + +const DEFAULT: NavigationPathInfo = { label: "", iconPath: mdiLink }; + +export const getShortcutCardDefaults = ( + hass: HomeAssistant, + action: ActionConfig | undefined, + navInfo: NavigationPathInfo +): NavigationPathInfo => { + switch (action?.action) { + case "navigate": + return navInfo; + case "assist": + return { + label: hass.localize( + "ui.panel.lovelace.editor.action-editor.actions.assist" + ), + iconPath: mdiMicrophone, + }; + case "call-service": + case "perform-action": + return { + label: action.perform_action || "", + iconPath: mdiRoomService, + }; + case "url": + return { + label: action.url_path || "", + iconPath: mdiOpenInNew, + }; + default: + return DEFAULT; + } +}; diff --git a/src/panels/lovelace/cards/hui-shortcut-card.ts b/src/panels/lovelace/cards/hui-shortcut-card.ts new file mode 100644 index 0000000000..b206d83184 --- /dev/null +++ b/src/panels/lovelace/cards/hui-shortcut-card.ts @@ -0,0 +1,207 @@ +import type { UnsubscribeFunc } from "home-assistant-js-websocket"; +import { css, html, LitElement, nothing, type PropertyValues } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { styleMap } from "lit/directives/style-map"; +import { computeCssColor } from "../../../common/color/compute-color"; +import "../../../components/ha-card"; +import "../../../components/tile/ha-tile-container"; +import "../../../components/tile/ha-tile-icon"; +import "../../../components/tile/ha-tile-info"; +import { + DEFAULT_NAVIGATION_PATH_INFO, + subscribeNavigationPathInfo, + type NavigationPathInfo, +} from "../../../data/compute-navigation-path-info"; +import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler"; +import type { HomeAssistant } from "../../../types"; +import { handleAction } from "../common/handle-action"; +import { hasAction } from "../common/has-action"; +import type { + LovelaceCard, + LovelaceCardEditor, + LovelaceGridOptions, +} from "../types"; +import { getShortcutCardDefaults } from "./hui-shortcut-card-defaults"; +import { tileCardStyle } from "./tile/tile-card-style"; +import type { ShortcutCardConfig } from "./types"; + +@customElement("hui-shortcut-card") +export class HuiShortcutCard extends LitElement implements LovelaceCard { + public static async getConfigElement(): Promise { + await import("../editor/config-elements/hui-shortcut-card-editor"); + return document.createElement("hui-shortcut-card-editor"); + } + + public static getStubConfig(): ShortcutCardConfig { + return { + type: "shortcut", + tap_action: { + action: "navigate", + navigation_path: "/home", + }, + }; + } + + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _config?: ShortcutCardConfig; + + private _navInfo: NavigationPathInfo = DEFAULT_NAVIGATION_PATH_INFO; + + private _unsubNavInfo?: UnsubscribeFunc; + + private _subscribedPath?: string; + + public setConfig(config: ShortcutCardConfig): void { + this._config = { + tap_action: { + action: "none", + }, + ...config, + }; + } + + public connectedCallback(): void { + super.connectedCallback(); + if (this.hasUpdated) { + this._updateNavInfo(); + } + } + + public disconnectedCallback(): void { + super.disconnectedCallback(); + this._unsubNavInfo?.(); + this._unsubNavInfo = undefined; + this._subscribedPath = undefined; + } + + public getCardSize(): number { + return this._config?.vertical ? 2 : 1; + } + + public getGridOptions(): LovelaceGridOptions { + if (this._config?.vertical) { + return { + columns: 6, + rows: 2, + min_columns: 3, + min_rows: 2, + }; + } + return { + columns: 6, + rows: 1, + min_columns: 6, + min_rows: 1, + }; + } + + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has("hass") || changedProps.has("_config")) { + this._updateNavInfo(); + } + } + + private _updateNavInfo(): void { + if (!this.hass) return; + + const action = this._config?.tap_action; + const navPath = + action?.action === "navigate" ? action.navigation_path : undefined; + + if (navPath === this._subscribedPath) return; + + this._unsubNavInfo?.(); + this._unsubNavInfo = undefined; + this._subscribedPath = navPath; + + if (!navPath) { + this._navInfo = DEFAULT_NAVIGATION_PATH_INFO; + return; + } + + this._unsubNavInfo = subscribeNavigationPathInfo( + this.hass, + navPath, + (info) => { + this._navInfo = info; + this.requestUpdate(); + } + ); + } + + private _handleAction(ev: ActionHandlerEvent) { + handleAction(this, this.hass!, this._config!, ev.detail.action!); + } + + private get _hasCardAction() { + return ( + hasAction(this._config?.tap_action) || + hasAction(this._config?.hold_action) || + hasAction(this._config?.double_tap_action) + ); + } + + protected render() { + if (!this._config || !this.hass) { + return nothing; + } + + const defaults = getShortcutCardDefaults( + this.hass, + this._config.tap_action, + this._navInfo + ); + const label = this._config.label || defaults.label; + const description = this._config.description; + const icon = this._config.icon || defaults.icon; + const iconPath = icon ? undefined : defaults.iconPath; + + const color = this._config.color + ? computeCssColor(this._config.color) + : undefined; + + const style = color ? { "--tile-color": color } : {}; + + return html` + + + + + ${label} + ${description + ? html`${description}` + : nothing} + + + + `; + } + + static styles = [ + tileCardStyle, + css` + :host { + --tile-color: var(--primary-color); + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-shortcut-card": HuiShortcutCard; + } +} diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index 369c38fbfc..c612364de1 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -674,6 +674,17 @@ export interface HomeSummaryCard extends LovelaceCardConfig { double_tap_action?: ActionConfig; } +export interface ShortcutCardConfig extends LovelaceCardConfig { + label?: string; + description?: string; + icon?: string; + color?: string; + vertical?: boolean; + tap_action?: ActionConfig; + hold_action?: ActionConfig; + double_tap_action?: ActionConfig; +} + export interface ToggleGroupCardConfig extends LovelaceCardConfig { title: string; entities: string[]; diff --git a/src/panels/lovelace/components/hui-action-editor.ts b/src/panels/lovelace/components/hui-action-editor.ts index 315be2994a..21e37a34e1 100644 --- a/src/panels/lovelace/components/hui-action-editor.ts +++ b/src/panels/lovelace/components/hui-action-editor.ts @@ -87,6 +87,8 @@ export class HuiActionEditor extends LitElement { @property({ attribute: false }) public hass?: HomeAssistant; + @property({ type: Boolean }) public required?: boolean; + @property({ attribute: false }) public context?: ActionRelatedContext; @@ -139,12 +141,25 @@ export class HuiActionEditor extends LitElement { const actions = this.actions ?? DEFAULT_ACTIONS; - let action = this.config?.action || "default"; + let action = this.config?.action || (this.required ? "" : "default"); if (action === "call-service") { action = "perform-action"; } + const defaultOption = { + value: "default", + label: `${this.hass!.localize( + "ui.panel.lovelace.editor.action-editor.actions.default_action" + )} ${ + this.defaultAction + ? ` (${this.hass!.localize( + `ui.panel.lovelace.editor.action-editor.actions.${this.defaultAction}` + ).toLowerCase()})` + : "" + }`, + }; + return html`