mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-14 00:58:26 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70cd87b881 | ||
|
|
60b6192db3 | ||
|
|
934ba2bc5e | ||
|
|
ff1f0f4dd8 | ||
|
|
1e663770cc | ||
|
|
5ea845f130 | ||
|
|
088136b3b7 | ||
|
|
3f18dedc45 | ||
|
|
6277031ae1 | ||
|
|
f22a80f2ca | ||
|
|
d7afa5afa4 | ||
|
|
c8f754e289 | ||
|
|
e2cb5bb67c |
No files matched your search
@@ -13,7 +13,6 @@ export const canOverrideAlphanumericInput = (composedPath: EventTarget[]) => {
|
||||
|
||||
if (
|
||||
el.tagName === "TEXTAREA" ||
|
||||
(el as HTMLElement).isContentEditable ||
|
||||
el.parentElement?.tagName === "HA-SELECT" ||
|
||||
el.parentElement?.tagName === "HA-DROPDOWN"
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { computeDomain } from "./compute_domain";
|
||||
|
||||
export const ALERT_DOMAINS = [
|
||||
"alarm_control_panel",
|
||||
"binary_sensor",
|
||||
"cover",
|
||||
"lock",
|
||||
];
|
||||
|
||||
export const DEFAULT_ALERT_COLOR = "red";
|
||||
|
||||
export const computeAlertActiveStates = (entityId: string): string[] => {
|
||||
switch (computeDomain(entityId)) {
|
||||
case "alarm_control_panel":
|
||||
return ["triggered"];
|
||||
case "cover":
|
||||
return ["open"];
|
||||
case "lock":
|
||||
return ["jammed", "unlocked", "open"];
|
||||
default:
|
||||
return ["on"];
|
||||
}
|
||||
};
|
||||
|
||||
export const isAlertActive = (stateObj: HassEntity): boolean =>
|
||||
computeAlertActiveStates(stateObj.entity_id).includes(stateObj.state);
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ALERT_DOMAINS } from "../../../common/entity/alert_active";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import type { AlertBadgeConfig } from "../badges/types";
|
||||
import type { BadgeSuggestionProvider } from "./types";
|
||||
|
||||
export const alertBadgeSuggestions: BadgeSuggestionProvider<AlertBadgeConfig> =
|
||||
{
|
||||
getEntitySuggestion(_hass, entityId) {
|
||||
if (!ALERT_DOMAINS.includes(computeDomain(entityId))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ config: { type: "alert", entity: entityId } }];
|
||||
},
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { alertBadgeSuggestions } from "./hui-alert-badge-suggestions";
|
||||
import { entityBadgeSuggestions } from "./hui-entity-badge-suggestions";
|
||||
import type { BadgeSuggestionProvider } from "./types";
|
||||
|
||||
@@ -6,4 +7,5 @@ export const BADGE_SUGGESTION_PROVIDERS: Record<
|
||||
BadgeSuggestionProvider
|
||||
> = {
|
||||
entity: entityBadgeSuggestions,
|
||||
alert: alertBadgeSuggestions,
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { mdiAlertCircle } from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { computeCssColor } from "../../../common/color/compute-color";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import {
|
||||
consumeEntityState,
|
||||
consumeLocalize,
|
||||
} from "../../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import {
|
||||
ALERT_DOMAINS,
|
||||
DEFAULT_ALERT_COLOR,
|
||||
isAlertActive,
|
||||
} from "../../../common/entity/alert_active";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
|
||||
import "../../../components/ha-badge";
|
||||
import "../../../components/ha-state-icon";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import { formattersContext } from "../../../data/context";
|
||||
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { actionHandler } from "../common/directives/action-handler-directive";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import type { LovelaceBadge, LovelaceBadgeEditor } from "../types";
|
||||
import type { AlertBadgeConfig } from "./types";
|
||||
|
||||
@customElement("hui-alert-badge")
|
||||
export class HuiAlertBadge extends LitElement implements LovelaceBadge {
|
||||
public static async getConfigElement(): Promise<LovelaceBadgeEditor> {
|
||||
await import("../editor/config-elements/hui-alert-badge-editor");
|
||||
|
||||
return document.createElement("hui-alert-badge-editor");
|
||||
}
|
||||
|
||||
public static getStubConfig(
|
||||
hass: HomeAssistant,
|
||||
entities: string[],
|
||||
entitiesFallback: string[]
|
||||
): AlertBadgeConfig {
|
||||
const [entity] = findEntities(
|
||||
hass,
|
||||
1,
|
||||
entities,
|
||||
entitiesFallback,
|
||||
ALERT_DOMAINS
|
||||
);
|
||||
|
||||
return { type: "alert", entity: entity || "" };
|
||||
}
|
||||
|
||||
@state() private _config?: AlertBadgeConfig;
|
||||
|
||||
@state()
|
||||
@consumeEntityState({ entityIdPath: ["_config", "entity"] })
|
||||
private _stateObj?: HassEntity;
|
||||
|
||||
@state()
|
||||
@consume({ context: formattersContext, subscribe: true })
|
||||
private _formatters!: ContextType<typeof formattersContext>;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
public setConfig(config: AlertBadgeConfig): void {
|
||||
if (
|
||||
!isValidEntityId(config.entity) ||
|
||||
(config.color !== undefined && typeof config.color !== "string") ||
|
||||
(config.inactive_color !== undefined &&
|
||||
typeof config.inactive_color !== "string") ||
|
||||
(config.pulse !== undefined && typeof config.pulse !== "boolean")
|
||||
) {
|
||||
throw new Error("Invalid configuration");
|
||||
}
|
||||
|
||||
if (!ALERT_DOMAINS.includes(computeDomain(config.entity))) {
|
||||
throw new Error(
|
||||
`Specify an entity from within the ${ALERT_DOMAINS.join(", ")} domains`
|
||||
);
|
||||
}
|
||||
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._config || !this._formatters || !this._localize) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const stateObj = this._stateObj;
|
||||
|
||||
if (!stateObj) {
|
||||
return html`
|
||||
<ha-badge .label=${this._config.entity} class="error">
|
||||
<ha-svg-icon slot="icon" .path=${mdiAlertCircle}></ha-svg-icon>
|
||||
${this._localize("ui.badge.entity.not_found")}
|
||||
</ha-badge>
|
||||
`;
|
||||
}
|
||||
|
||||
const active = isAlertActive(stateObj);
|
||||
|
||||
const color = active
|
||||
? (this._config.color ?? DEFAULT_ALERT_COLOR)
|
||||
: this._config.inactive_color;
|
||||
|
||||
const pulse = active && this._config.pulse === true;
|
||||
const hasColor = color !== undefined && color !== "none";
|
||||
|
||||
return html`
|
||||
<ha-badge
|
||||
type="button"
|
||||
.label=${this._formatters.formatEntityName(stateObj)}
|
||||
class=${classMap({ alert: true, pulse, "no-color": !hasColor })}
|
||||
style=${styleMap({
|
||||
"--ha-alert-color":
|
||||
color && hasColor ? computeCssColor(color) : undefined,
|
||||
})}
|
||||
@action=${this._handleAction}
|
||||
.actionHandler=${actionHandler({
|
||||
hasHold: false,
|
||||
hasDoubleClick: false,
|
||||
})}
|
||||
>
|
||||
<ha-state-icon slot="icon" .stateObj=${stateObj}></ha-state-icon>
|
||||
${this._formatters.formatEntityState(stateObj)}
|
||||
</ha-badge>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleAction(ev: ActionHandlerEvent): void {
|
||||
if (ev.detail.action === "tap" && this._config) {
|
||||
fireEvent(this, "hass-more-info", { entityId: this._config.entity });
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
@keyframes pulse-opacity {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: var(--ha-alert-pulse-opacity);
|
||||
}
|
||||
}
|
||||
:host {
|
||||
--ha-alert-pulse-duration: 1s;
|
||||
--ha-alert-pulse-opacity: 0.3;
|
||||
--ha-alert-badge-background: var(
|
||||
--ha-card-background,
|
||||
var(--card-background-color, white)
|
||||
);
|
||||
}
|
||||
ha-badge.error {
|
||||
--badge-color: var(--red-color);
|
||||
}
|
||||
ha-badge.alert {
|
||||
display: block;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
border-radius: var(
|
||||
--ha-badge-border-radius,
|
||||
calc(var(--ha-badge-size, 36px) / 2)
|
||||
);
|
||||
background: var(--ha-alert-badge-background);
|
||||
--ha-card-background: transparent;
|
||||
--badge-color: var(--ha-alert-color);
|
||||
}
|
||||
ha-badge.alert.no-color {
|
||||
--badge-color: var(--secondary-text-color);
|
||||
}
|
||||
ha-badge.alert::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
border-radius: inherit;
|
||||
background-color: var(--ha-alert-color);
|
||||
content: "";
|
||||
opacity: var(--ha-alert-pulse-opacity);
|
||||
pointer-events: none;
|
||||
}
|
||||
ha-badge.alert.pulse::before {
|
||||
animation: pulse-opacity var(--ha-alert-pulse-duration) ease-in-out
|
||||
infinite alternate;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
ha-badge.alert.pulse::before {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-alert-badge": HuiAlertBadge;
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,14 @@ export interface StateLabelBadgeConfig extends LovelaceBadgeConfig {
|
||||
double_tap_action?: ActionConfig;
|
||||
}
|
||||
|
||||
export interface AlertBadgeConfig extends LovelaceBadgeConfig {
|
||||
type: "alert";
|
||||
entity: string;
|
||||
color?: string;
|
||||
inactive_color?: string;
|
||||
pulse?: boolean;
|
||||
}
|
||||
|
||||
export interface ShortcutBadgeConfig extends LovelaceBadgeConfig {
|
||||
text?: string;
|
||||
icon?: string;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ALERT_DOMAINS } from "../../../common/entity/alert_active";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import type { AlertCardConfig } from "../cards/types";
|
||||
import type { CardSuggestionProvider } from "./types";
|
||||
|
||||
export const alertCardSuggestions: CardSuggestionProvider<AlertCardConfig> = {
|
||||
getEntitySuggestion(_hass, entityId) {
|
||||
if (!ALERT_DOMAINS.includes(computeDomain(entityId))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { config: { type: "alert", entity: entityId } };
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { alarmPanelCardSuggestions } from "./hui-alarm-panel-card-suggestions";
|
||||
import { alertCardSuggestions } from "./hui-alert-card-suggestions";
|
||||
import { calendarCardSuggestions } from "./hui-calendar-card-suggestions";
|
||||
import { gaugeCardSuggestions } from "./hui-gauge-card-suggestions";
|
||||
import { historyGraphCardSuggestions } from "./hui-history-graph-card-suggestions";
|
||||
@@ -19,6 +20,7 @@ export const CARD_SUGGESTION_PROVIDERS: Record<string, CardSuggestionProvider> =
|
||||
{
|
||||
tile: tileCardSuggestions,
|
||||
"alarm-panel": alarmPanelCardSuggestions,
|
||||
alert: alertCardSuggestions,
|
||||
calendar: calendarCardSuggestions,
|
||||
gauge: gaugeCardSuggestions,
|
||||
humidifier: humidifierCardSuggestions,
|
||||
|
||||
@@ -8,6 +8,12 @@ import { consume } from "../../../common/decorators/consume";
|
||||
import { computeCssColor } from "../../../common/color/compute-color";
|
||||
import { consumeEntityState } from "../../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import {
|
||||
ALERT_DOMAINS,
|
||||
DEFAULT_ALERT_COLOR,
|
||||
isAlertActive,
|
||||
} from "../../../common/entity/alert_active";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-relative-time";
|
||||
@@ -17,12 +23,40 @@ import "../../../components/tile/ha-tile-icon";
|
||||
import "../../../components/tile/ha-tile-info";
|
||||
import { formattersContext } from "../../../data/context";
|
||||
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
|
||||
import type { LovelaceCard, LovelaceGridOptions } from "../types";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import type {
|
||||
LovelaceCard,
|
||||
LovelaceCardEditor,
|
||||
LovelaceGridOptions,
|
||||
} from "../types";
|
||||
import { tileCardStyle } from "./tile/tile-card-style";
|
||||
import type { AlertCardConfig } from "./types";
|
||||
|
||||
@customElement("hui-alert-card")
|
||||
export class HuiAlertCard extends LitElement implements LovelaceCard {
|
||||
public static async getConfigElement(): Promise<LovelaceCardEditor> {
|
||||
await import("../editor/config-elements/hui-alert-card-editor");
|
||||
|
||||
return document.createElement("hui-alert-card-editor");
|
||||
}
|
||||
|
||||
public static getStubConfig(
|
||||
hass: HomeAssistant,
|
||||
entities: string[],
|
||||
entitiesFallback: string[]
|
||||
): AlertCardConfig {
|
||||
const [entity] = findEntities(
|
||||
hass,
|
||||
1,
|
||||
entities,
|
||||
entitiesFallback,
|
||||
ALERT_DOMAINS
|
||||
);
|
||||
|
||||
return { type: "alert", entity: entity || "" };
|
||||
}
|
||||
|
||||
@state() private _config?: AlertCardConfig;
|
||||
|
||||
@state()
|
||||
@@ -37,10 +71,18 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
|
||||
if (
|
||||
!isValidEntityId(config.entity) ||
|
||||
(config.color !== undefined && typeof config.color !== "string") ||
|
||||
(config.inactive_color !== undefined &&
|
||||
typeof config.inactive_color !== "string") ||
|
||||
(config.pulse !== undefined && typeof config.pulse !== "boolean")
|
||||
) {
|
||||
throw new Error("Invalid configuration");
|
||||
}
|
||||
|
||||
if (!ALERT_DOMAINS.includes(computeDomain(config.entity))) {
|
||||
throw new Error(
|
||||
`Specify an entity from within the ${ALERT_DOMAINS.join(", ")} domains`
|
||||
);
|
||||
}
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
@@ -71,18 +113,22 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
|
||||
const areaName = this._formatters.formatEntityName(stateObj, {
|
||||
type: "area",
|
||||
});
|
||||
const pulse = this._config.pulse === true;
|
||||
const hasColor =
|
||||
this._config.color !== undefined && this._config.color !== "none";
|
||||
|
||||
const active = isAlertActive(stateObj);
|
||||
|
||||
const color = active
|
||||
? (this._config.color ?? DEFAULT_ALERT_COLOR)
|
||||
: this._config.inactive_color;
|
||||
|
||||
const pulse = active && this._config.pulse === true;
|
||||
const hasColor = color !== undefined && color !== "none";
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
class=${classMap({ pulse, "no-color": !hasColor })}
|
||||
style=${styleMap({
|
||||
"--ha-alert-color":
|
||||
this._config.color && hasColor
|
||||
? computeCssColor(this._config.color)
|
||||
: undefined,
|
||||
color && hasColor ? computeCssColor(color) : undefined,
|
||||
"--ha-alert-static-opacity": pulse
|
||||
? undefined
|
||||
: "var(--ha-alert-pulse-opacity)",
|
||||
@@ -169,7 +215,8 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
|
||||
position: relative;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
ha-card::before {
|
||||
ha-card::before,
|
||||
ha-card.pulse::before {
|
||||
animation: none;
|
||||
opacity: var(--ha-alert-pulse-opacity);
|
||||
}
|
||||
|
||||
@@ -740,6 +740,7 @@ export interface AlertCardConfig extends LovelaceCardConfig {
|
||||
type: "alert";
|
||||
entity: string;
|
||||
color?: string;
|
||||
inactive_color?: string;
|
||||
pulse?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
|
||||
const ALWAYS_LOADED_TYPES = new Set(["error", "entity"]);
|
||||
const LAZY_LOAD_TYPES = {
|
||||
alert: () => import("../badges/hui-alert-badge"),
|
||||
"entity-filter": () => import("../badges/hui-entity-filter-badge"),
|
||||
shortcut: () => import("../badges/hui-shortcut-badge"),
|
||||
"state-label": () => import("../badges/hui-state-label-badge"),
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { assert, assign, boolean, object, optional, string } from "superstruct";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import {
|
||||
ALERT_DOMAINS,
|
||||
DEFAULT_ALERT_COLOR,
|
||||
} from "../../../../common/entity/alert_active";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../../../../components/ha-form/types";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import type { AlertBadgeConfig } from "../../badges/types";
|
||||
import type { LovelaceBadgeEditor } from "../../types";
|
||||
import { baseLovelaceBadgeConfig } from "../structs/base-badge-struct";
|
||||
|
||||
const badgeConfigStruct = assign(
|
||||
baseLovelaceBadgeConfig,
|
||||
object({
|
||||
entity: optional(string()),
|
||||
color: optional(string()),
|
||||
inactive_color: optional(string()),
|
||||
pulse: optional(boolean()),
|
||||
})
|
||||
);
|
||||
|
||||
const SCHEMA = [
|
||||
{
|
||||
name: "entity",
|
||||
required: true,
|
||||
selector: { entity: { filter: { domain: ALERT_DOMAINS } } },
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "inactive_color",
|
||||
selector: { ui_color: { include_none: true, default_color: "none" } },
|
||||
},
|
||||
{
|
||||
name: "color",
|
||||
selector: {
|
||||
ui_color: { include_none: true, default_color: DEFAULT_ALERT_COLOR },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ name: "pulse", selector: { boolean: {} } },
|
||||
] as const;
|
||||
|
||||
@customElement("hui-alert-badge-editor")
|
||||
export class HuiAlertBadgeEditor
|
||||
extends LitElement
|
||||
implements LovelaceBadgeEditor
|
||||
{
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@state() private _config?: AlertBadgeConfig;
|
||||
|
||||
public setConfig(config: AlertBadgeConfig): void {
|
||||
assert(config, badgeConfigStruct);
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._config) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._config}
|
||||
.schema=${SCHEMA}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
.computeHelper=${this._computeHelperCallback}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueChanged(ev: ValueChangedEvent<AlertBadgeConfig>): void {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "config-changed", { config: ev.detail.value });
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
|
||||
if (schema.name === "pulse" || schema.name === "inactive_color") {
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.alert.${schema.name}`
|
||||
);
|
||||
}
|
||||
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.generic.${schema.name}`
|
||||
);
|
||||
};
|
||||
|
||||
private _computeHelperCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
|
||||
if (
|
||||
schema.name === "pulse" ||
|
||||
schema.name === "color" ||
|
||||
schema.name === "inactive_color"
|
||||
) {
|
||||
return this.hass?.localize(
|
||||
`ui.panel.lovelace.editor.card.alert.${schema.name}_helper`
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-alert-badge-editor": HuiAlertBadgeEditor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { assert, assign, boolean, object, optional, string } from "superstruct";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import {
|
||||
ALERT_DOMAINS,
|
||||
DEFAULT_ALERT_COLOR,
|
||||
} from "../../../../common/entity/alert_active";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../../../../components/ha-form/types";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import type { AlertCardConfig } from "../../cards/types";
|
||||
import type { LovelaceCardEditor } from "../../types";
|
||||
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
|
||||
|
||||
const cardConfigStruct = assign(
|
||||
baseLovelaceCardConfig,
|
||||
object({
|
||||
entity: optional(string()),
|
||||
color: optional(string()),
|
||||
inactive_color: optional(string()),
|
||||
pulse: optional(boolean()),
|
||||
})
|
||||
);
|
||||
|
||||
const SCHEMA = [
|
||||
{
|
||||
name: "entity",
|
||||
required: true,
|
||||
selector: { entity: { filter: { domain: ALERT_DOMAINS } } },
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "inactive_color",
|
||||
selector: { ui_color: { include_none: true, default_color: "none" } },
|
||||
},
|
||||
{
|
||||
name: "color",
|
||||
selector: {
|
||||
ui_color: { include_none: true, default_color: DEFAULT_ALERT_COLOR },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ name: "pulse", selector: { boolean: {} } },
|
||||
] as const;
|
||||
|
||||
@customElement("hui-alert-card-editor")
|
||||
export class HuiAlertCardEditor
|
||||
extends LitElement
|
||||
implements LovelaceCardEditor
|
||||
{
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@state() private _config?: AlertCardConfig;
|
||||
|
||||
public setConfig(config: AlertCardConfig): void {
|
||||
assert(config, cardConfigStruct);
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._config) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._config}
|
||||
.schema=${SCHEMA}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
.computeHelper=${this._computeHelperCallback}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueChanged(ev: ValueChangedEvent<AlertCardConfig>): void {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "config-changed", { config: ev.detail.value });
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
|
||||
if (schema.name === "pulse" || schema.name === "inactive_color") {
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.alert.${schema.name}`
|
||||
);
|
||||
}
|
||||
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.generic.${schema.name}`
|
||||
);
|
||||
};
|
||||
|
||||
private _computeHelperCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
|
||||
if (
|
||||
schema.name === "pulse" ||
|
||||
schema.name === "color" ||
|
||||
schema.name === "inactive_color"
|
||||
) {
|
||||
return this.hass?.localize(
|
||||
`ui.panel.lovelace.editor.card.alert.${schema.name}_helper`
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-alert-card-editor": HuiAlertCardEditor;
|
||||
}
|
||||
}
|
||||
@@ -9,4 +9,8 @@ export const coreBadges: Badge[] = [
|
||||
type: "shortcut",
|
||||
showElement: true,
|
||||
},
|
||||
{
|
||||
type: "alert",
|
||||
showElement: true,
|
||||
},
|
||||
];
|
||||
@@ -141,6 +141,10 @@ export const coreCards: Card[] = [
|
||||
type: "shortcut",
|
||||
showElement: true,
|
||||
},
|
||||
{
|
||||
type: "alert",
|
||||
showElement: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const energyCards: Card[] = [
|
||||
|
||||
@@ -4,6 +4,7 @@ import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import { ALERT_DOMAINS } from "../../../common/entity/alert_active";
|
||||
import { computeEntityPickerDisplay } from "../../../common/entity/compute_entity_name_display";
|
||||
import {
|
||||
fireEvent,
|
||||
@@ -94,6 +95,7 @@ export class SecurityAlertsEditor extends LitElement {
|
||||
"ui.panel.security.editor.add_alert_entity"
|
||||
)}
|
||||
.excludeEntities=${this.alertEntities.map(({ entity }) => entity)}
|
||||
.includeDomains=${ALERT_DOMAINS}
|
||||
.entityFilter=${this._entityFilter}
|
||||
@value-changed=${this._add}
|
||||
></ha-entity-picker>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { ensureArray } from "../../../common/array/ensure-array";
|
||||
import {
|
||||
computeAlertActiveStates,
|
||||
isAlertActive,
|
||||
} from "../../../common/entity/alert_active";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import type {
|
||||
SecurityAlertEntityConfig,
|
||||
@@ -55,26 +58,15 @@ export const computeDefaultSecurityAlertSeverity = (
|
||||
export const computeDefaultSecurityAlertVisibility = (
|
||||
entityId: string
|
||||
): StateCondition[] => {
|
||||
const condition: StateCondition = {
|
||||
condition: "state",
|
||||
entity: entityId,
|
||||
};
|
||||
const states = computeAlertActiveStates(entityId);
|
||||
|
||||
switch (computeDomain(entityId)) {
|
||||
case "alarm_control_panel":
|
||||
condition.state = "triggered";
|
||||
break;
|
||||
case "cover":
|
||||
condition.state = "open";
|
||||
break;
|
||||
case "lock":
|
||||
condition.state = ["jammed", "unlocked", "open"];
|
||||
break;
|
||||
default:
|
||||
condition.state = "on";
|
||||
}
|
||||
|
||||
return [condition];
|
||||
return [
|
||||
{
|
||||
condition: "state",
|
||||
entity: entityId,
|
||||
state: states.length === 1 ? states[0] : states,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const resolveSecurityAlertSeverity = (
|
||||
@@ -88,8 +80,8 @@ export const isSecurityAlertActive = (
|
||||
entityId: string
|
||||
): boolean => {
|
||||
const stateObj = states[entityId];
|
||||
const [{ state }] = computeDefaultSecurityAlertVisibility(entityId);
|
||||
return !!stateObj && !!state && ensureArray(state).includes(stateObj.state);
|
||||
|
||||
return !!stateObj && isAlertActive(stateObj);
|
||||
};
|
||||
|
||||
export const filterSecurityAlertEntities = (
|
||||
|
||||
@@ -3,6 +3,8 @@ import { ReactiveElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { ALERT_DOMAINS } from "../../../common/entity/alert_active";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import {
|
||||
findEntities,
|
||||
generateEntityFilter,
|
||||
@@ -313,12 +315,16 @@ export class SecurityViewStrategy extends ReactiveElement {
|
||||
|
||||
const sidebarSections: LovelaceSectionConfig[] = [];
|
||||
|
||||
const alertCards = config.alert_entities?.map((alertEntity) =>
|
||||
computeSecurityAlertCardConfig(
|
||||
hass.states[alertEntity.entity],
|
||||
alertEntity
|
||||
const alertCards = config.alert_entities
|
||||
?.filter((alertEntity) =>
|
||||
ALERT_DOMAINS.includes(computeDomain(alertEntity.entity))
|
||||
)
|
||||
);
|
||||
.map((alertEntity) =>
|
||||
computeSecurityAlertCardConfig(
|
||||
hass.states[alertEntity.entity],
|
||||
alertEntity
|
||||
)
|
||||
);
|
||||
|
||||
if (alertCards?.length) {
|
||||
sidebarSections.push({
|
||||
|
||||
@@ -10317,6 +10317,15 @@
|
||||
"available_states": "Available states",
|
||||
"description": "This card allows you to arm and disarm your alarm control panel integrations."
|
||||
},
|
||||
"alert": {
|
||||
"name": "Alert",
|
||||
"description": "This card highlights an entity that needs attention, with an optional color and pulsing background.",
|
||||
"color_helper": "Used while the entity is in an alert state, such as a binary sensor that is on or an unlocked lock.",
|
||||
"inactive_color": "Inactive color",
|
||||
"inactive_color_helper": "Used when the entity isn't in an alert state. The background doesn't pulse.",
|
||||
"pulse": "Pulse",
|
||||
"pulse_helper": "Animates the colored background while the entity is in an alert state. When reduced motion is on, the background stays tinted instead."
|
||||
},
|
||||
"area": {
|
||||
"name": "Area",
|
||||
"color": "[%key:ui::panel::lovelace::editor::card::tile::color%]",
|
||||
@@ -11111,6 +11120,10 @@
|
||||
"state_filter": "[%key:ui::panel::lovelace::editor::card::picture-elements::state_filter%]"
|
||||
},
|
||||
"badge": {
|
||||
"alert": {
|
||||
"name": "Alert",
|
||||
"description": "A badge that highlights an entity that needs attention, with an optional color and pulsing background."
|
||||
},
|
||||
"entity": {
|
||||
"name": "Entity",
|
||||
"description": "The Entity badge gives you a quick overview of an entity.",
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canOverrideAlphanumericInput } from "../../../src/common/dom/can-override-input";
|
||||
|
||||
// jsdom does not implement isContentEditable — it is undefined on every element,
|
||||
// including one carrying contenteditable="true". These stubs therefore describe
|
||||
// the shape the function actually reads rather than using real elements.
|
||||
const stub = (props: Record<string, unknown>) =>
|
||||
props as unknown as EventTarget;
|
||||
|
||||
describe("canOverrideAlphanumericInput", () => {
|
||||
it("allows shortcuts on a plain element", () => {
|
||||
expect(canOverrideAlphanumericInput([stub({ tagName: "DIV" })])).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks shortcuts in a textarea", () => {
|
||||
expect(canOverrideAlphanumericInput([stub({ tagName: "TEXTAREA" })])).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks shortcuts in a text input", () => {
|
||||
expect(
|
||||
canOverrideAlphanumericInput([stub({ tagName: "INPUT", type: "text" })])
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows shortcuts on inputs that are not typed into", () => {
|
||||
for (const type of ["button", "checkbox", "hidden", "radio", "range"]) {
|
||||
expect(
|
||||
canOverrideAlphanumericInput([stub({ tagName: "INPUT", type })])
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks shortcuts in a contenteditable element", () => {
|
||||
expect(
|
||||
canOverrideAlphanumericInput([
|
||||
stub({ tagName: "DIV", isContentEditable: true }),
|
||||
])
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks shortcuts when a contenteditable element is the event target inside a shadow root", () => {
|
||||
expect(
|
||||
canOverrideAlphanumericInput([
|
||||
stub({ tagName: "SPAN", isContentEditable: true }),
|
||||
stub({ tagName: "DIV" }),
|
||||
stub({ tagName: "MY-CARD" }),
|
||||
])
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks shortcuts inside a code editor", () => {
|
||||
expect(
|
||||
canOverrideAlphanumericInput([
|
||||
stub({ tagName: "DIV" }),
|
||||
stub({ tagName: "HA-CODE-EDITOR" }),
|
||||
])
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks shortcuts inside a menu", () => {
|
||||
expect(
|
||||
canOverrideAlphanumericInput([
|
||||
stub({ tagName: "DIV" }),
|
||||
stub({ tagName: "HA-MENU" }),
|
||||
])
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks shortcuts on a select or dropdown child", () => {
|
||||
expect(
|
||||
canOverrideAlphanumericInput([
|
||||
stub({ tagName: "DIV", parentElement: { tagName: "HA-SELECT" } }),
|
||||
])
|
||||
).toBe(false);
|
||||
expect(
|
||||
canOverrideAlphanumericInput([
|
||||
stub({ tagName: "DIV", parentElement: { tagName: "HA-DROPDOWN" } }),
|
||||
])
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user