diff --git a/package.json b/package.json index c894a47693..1ca5d641b5 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@fullcalendar/list": "6.1.5", "@fullcalendar/timegrid": "6.1.5", "@lezer/highlight": "1.1.3", + "@lit-labs/context": "0.2.0", "@lit-labs/motion": "1.0.3", "@lit-labs/virtualizer": "1.0.1", "@material/chips": "=14.0.0-canary.53b3cad2f.0", diff --git a/src/common/decorators/transform.ts b/src/common/decorators/transform.ts new file mode 100644 index 0000000000..312dac5bd1 --- /dev/null +++ b/src/common/decorators/transform.ts @@ -0,0 +1,111 @@ +import { PropertyDeclaration, PropertyValues, ReactiveElement } from "lit"; +import { ClassElement } from "../../types"; +import { shallowEqual } from "../util/shallow-equal"; + +/** + * Transform function type. + */ +export interface Transformer { + (value: V): T; +} + +type ReactiveTransformElement = ReactiveElement & { + _transformers: Map; + _watching: Map>; +}; + +type ReactiveElementClassWithTransformers = typeof ReactiveElement & { + prototype: ReactiveTransformElement; +}; + +/** + * Specifies an tranformer callback that is run when the value of the decorated property, or any of the properties in the watching array, changes. + * The result of the tranformer is assigned to the decorated property. + * The tranformer receives the current as arguments. + */ +export const transform = + (config: { + transformer: Transformer; + watch?: PropertyKey[]; + propertyOptions?: PropertyDeclaration; + }): any => + (clsElement: ClassElement) => { + const key = String(clsElement.key); + return { + ...clsElement, + kind: "method", + descriptor: { + set(this: ReactiveTransformElement, value: V) { + const oldValue = this[`__transform_${key}`]; + const trnsformr: Transformer | undefined = + this._transformers.get(key); + if (trnsformr) { + this[`__transform_${key}`] = trnsformr.call(this, value); + } else { + this[`__transform_${key}`] = value; + } + this[`__original_${key}`] = value; + this.requestUpdate(key, oldValue); + }, + get(): T { + return this[`__transform_${key}`]; + }, + enumerable: true, + configurable: true, + }, + finisher(cls: ReactiveElementClassWithTransformers) { + // if we haven't wrapped `willUpdate` in this class, do so + if (!cls.prototype._transformers) { + cls.prototype._transformers = new Map(); + cls.prototype._watching = new Map>(); + // @ts-ignore + const userWillUpdate = cls.prototype.willUpdate; + // @ts-ignore + cls.prototype.willUpdate = function ( + this: ReactiveTransformElement, + changedProperties: PropertyValues + ) { + userWillUpdate.call(this, changedProperties); + const keys = new Set(); + changedProperties.forEach((_v, k) => { + const watchers = this._watching; + const ks: Set | undefined = watchers.get(k); + if (ks !== undefined) { + ks.forEach((wk) => keys.add(wk)); + } + }); + keys.forEach((k) => { + // trigger setter + this[k] = this[`__original_${String(k)}`]; + }); + }; + // clone any existing observers (superclasses) + // eslint-disable-next-line no-prototype-builtins + } else if (!cls.prototype.hasOwnProperty("_transformers")) { + const tranformers = cls.prototype._transformers; + cls.prototype._transformers = new Map(); + tranformers.forEach((v: any, k: PropertyKey) => + cls.prototype._transformers.set(k, v) + ); + } + // set this method + cls.prototype._transformers.set(clsElement.key, config.transformer); + if (config.watch) { + // store watchers + config.watch.forEach((k) => { + let curWatch = cls.prototype._watching.get(k); + if (!curWatch) { + curWatch = new Set(); + cls.prototype._watching.set(k, curWatch); + } + curWatch.add(clsElement.key); + }); + } + cls.createProperty(clsElement.key, { + noAccessor: true, + hasChanged: (v: any, o: any) => !shallowEqual(v, o), + ...config.propertyOptions, + }); + }, + }; + }; diff --git a/src/common/util/shallow-equal.ts b/src/common/util/shallow-equal.ts new file mode 100644 index 0000000000..d28a00a98c --- /dev/null +++ b/src/common/util/shallow-equal.ts @@ -0,0 +1,108 @@ +/** + * Compares two values for shallow equality, only 1 level deep. + */ +export const shallowEqual = (a: any, b: any): boolean => { + if (a === b) { + return true; + } + + if (a && b && typeof a === "object" && typeof b === "object") { + if (a.constructor !== b.constructor) { + return false; + } + + let i: number | [any, any]; + let length: number; + if (Array.isArray(a)) { + length = a.length; + if (length !== b.length) { + return false; + } + for (i = length; i-- !== 0; ) { + if (a[i] !== b[i]) { + return false; + } + } + return true; + } + + if (a instanceof Map && b instanceof Map) { + if (a.size !== b.size) { + return false; + } + for (i of a.entries()) { + if (!b.has(i[0])) { + return false; + } + } + for (i of a.entries()) { + if (i[1] !== b.get(i[0])) { + return false; + } + } + return true; + } + + if (a instanceof Set && b instanceof Set) { + if (a.size !== b.size) { + return false; + } + for (i of a.entries()) { + if (!b.has(i[0])) { + return false; + } + } + return true; + } + + if (ArrayBuffer.isView(a) && ArrayBuffer.isView(b)) { + // @ts-ignore + length = a.length; + // @ts-ignore + if (length !== b.length) { + return false; + } + for (i = length; i-- !== 0; ) { + if (a[i] !== b[i]) { + return false; + } + } + return true; + } + + if (a.constructor === RegExp) { + return a.source === b.source && a.flags === b.flags; + } + if (a.valueOf !== Object.prototype.valueOf) { + return a.valueOf() === b.valueOf(); + } + if (a.toString !== Object.prototype.toString) { + return a.toString() === b.toString(); + } + + const keys = Object.keys(a); + length = keys.length; + if (length !== Object.keys(b).length) { + return false; + } + for (i = length; i-- !== 0; ) { + if (!Object.prototype.hasOwnProperty.call(b, keys[i])) { + return false; + } + } + + for (i = length; i-- !== 0; ) { + const key = keys[i]; + + if (a[key] !== b[key]) { + return false; + } + } + + return true; + } + + // true if both NaN, false otherwise + // eslint-disable-next-line no-self-compare + return a !== a && b !== b; +}; diff --git a/src/data/context.ts b/src/data/context.ts new file mode 100644 index 0000000000..2146180b30 --- /dev/null +++ b/src/data/context.ts @@ -0,0 +1,24 @@ +import { createContext } from "@lit-labs/context"; +import { HomeAssistant } from "../types"; +import { EntityRegistryEntry } from "./entity_registry"; + +export const statesContext = createContext("states"); +export const entitiesContext = + createContext("entities"); +export const devicesContext = + createContext("devices"); +export const areasContext = createContext("areas"); +export const localizeContext = + createContext("localize"); +export const localeContext = createContext("locale"); +export const configContext = createContext("config"); +export const themesContext = createContext("themes"); +export const selectedThemeContext = + createContext("selectedTheme"); +export const userContext = createContext("user"); +export const userDataContext = + createContext("userData"); +export const panelsContext = createContext("panels"); + +export const extendedEntitiesContext = + createContext("extendedEntities"); diff --git a/src/layouts/home-assistant-main.ts b/src/layouts/home-assistant-main.ts index 5024927d77..d8253925b9 100644 --- a/src/layouts/home-assistant-main.ts +++ b/src/layouts/home-assistant-main.ts @@ -181,6 +181,10 @@ export class HomeAssistantMain extends LitElement { this.drawer.close(); } + if (!changedProps.has("hass")) { + return; + } + const oldHass = changedProps.get("hass") as HomeAssistant | undefined; // Make app-drawer adjust to a potential LTR/RTL change diff --git a/src/panels/lovelace/cards/hui-button-card.ts b/src/panels/lovelace/cards/hui-button-card.ts index 3886e2b83d..0183bc1ba2 100644 --- a/src/panels/lovelace/cards/hui-button-card.ts +++ b/src/panels/lovelace/cards/hui-button-card.ts @@ -1,25 +1,21 @@ +import { consume } from "@lit-labs/context"; import "@material/mwc-ripple"; import type { Ripple } from "@material/mwc-ripple"; import { RippleHandlers } from "@material/mwc-ripple/ripple-handlers"; -import { HassEntity } from "home-assistant-js-websocket"; +import { HassEntities, HassEntity } from "home-assistant-js-websocket"; import { - css, CSSResultGroup, - html, LitElement, PropertyValues, + css, + html, nothing, } from "lit"; -import { - customElement, - eventOptions, - property, - queryAsync, - state, -} from "lit/decorators"; +import { customElement, eventOptions, queryAsync, state } from "lit/decorators"; import { ifDefined } from "lit/directives/if-defined"; import { styleMap } from "lit/directives/style-map"; import { DOMAINS_TOGGLE } from "../../../common/const"; +import { transform } from "../../../common/decorators/transform"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; import { computeDomain } from "../../../common/entity/compute_domain"; import { computeStateDisplay } from "../../../common/entity/compute_state_display"; @@ -30,6 +26,13 @@ import { isValidEntityId } from "../../../common/entity/valid_entity_id"; import { iconColorCSS } from "../../../common/style/icon_color_css"; import "../../../components/ha-card"; import { HVAC_ACTION_TO_MODE } from "../../../data/climate"; +import { + entitiesContext, + localeContext, + localizeContext, + statesContext, + themesContext, +} from "../../../data/context"; import { LightEntity } from "../../../data/light"; import { ActionHandlerEvent } from "../../../data/lovelace"; import { HomeAssistant } from "../../../types"; @@ -40,6 +43,9 @@ import { hasAction } from "../common/has-action"; import { createEntityNotFoundWarning } from "../components/hui-warning"; import { LovelaceCard, LovelaceCardEditor } from "../types"; import { ButtonCardConfig } from "./types"; +import { LocalizeFunc } from "../../../common/translations/localize"; +import { FrontendLocaleData } from "../../../data/translation"; +import { Themes } from "../../../data/ws-themes"; @customElement("hui-button-card") export class HuiButtonCard extends LitElement implements LovelaceCard { @@ -71,10 +77,39 @@ export class HuiButtonCard extends LitElement implements LovelaceCard { }; } - @property({ attribute: false }) public hass?: HomeAssistant; + public hass!: HomeAssistant; @state() private _config?: ButtonCardConfig; + @consume({ context: statesContext, subscribe: true }) + @transform({ + transformer: function (this: HuiButtonCard, value: HassEntities) { + return this._config?.entity ? value[this._config?.entity] : undefined; + }, + watch: ["_config"], + }) + _stateObj?: HassEntity; + + @consume({ context: themesContext, subscribe: true }) + _themes!: Themes; + + @consume({ context: localizeContext, subscribe: true }) + _localize!: LocalizeFunc; + + @consume({ context: localeContext, subscribe: true }) + _locale!: FrontendLocaleData; + + @consume({ context: entitiesContext, subscribe: true }) + @transform({ + transformer: function (this: HuiButtonCard, value) { + return this._config?.entity + ? { [this._config?.entity]: value[this._config?.entity] } + : {}; + }, + watch: ["_config"], + }) + _entities!: HomeAssistant["entities"]; + @queryAsync("mwc-ripple") private _ripple!: Promise; @state() private _shouldRenderRipple = false; @@ -114,35 +149,11 @@ export class HuiButtonCard extends LitElement implements LovelaceCard { }; } - protected shouldUpdate(changedProps: PropertyValues): boolean { - if (changedProps.has("_config")) { - return true; - } - - const oldHass = changedProps.get("hass") as HomeAssistant | undefined; - - if ( - !oldHass || - oldHass.themes !== this.hass!.themes || - oldHass.locale !== this.hass!.locale - ) { - return true; - } - - return ( - Boolean(this._config!.entity) && - oldHass.states[this._config!.entity!] !== - this.hass!.states[this._config!.entity!] - ); - } - protected render() { - if (!this._config || !this.hass) { + if (!this._config) { return nothing; } - const stateObj = this._config.entity - ? this.hass.states[this._config.entity] - : undefined; + const stateObj = this._stateObj; if (this._config.entity && !stateObj) { return html` @@ -207,10 +218,10 @@ export class HuiButtonCard extends LitElement implements LovelaceCard { ${this._config.show_state && stateObj ? html` ${computeStateDisplay( - this.hass.localize, + this._localize, stateObj, - this.hass.locale, - this.hass.entities + this._locale, + this._entities )} ` : ""} @@ -221,21 +232,23 @@ export class HuiButtonCard extends LitElement implements LovelaceCard { protected updated(changedProps: PropertyValues): void { super.updated(changedProps); - if (!this._config || !this.hass) { + if (!this._config) { return; } - const oldHass = changedProps.get("hass") as HomeAssistant | undefined; + const oldThemes = changedProps.get("_themes") as + | HomeAssistant["themes"] + | undefined; const oldConfig = changedProps.get("_config") as | ButtonCardConfig | undefined; if ( - !oldHass || + !oldThemes || !oldConfig || - oldHass.themes !== this.hass.themes || + oldThemes !== this._themes || oldConfig.theme !== this._config.theme ) { - applyThemesOnElement(this, this.hass.themes, this._config.theme); + applyThemesOnElement(this, this._themes, this._config.theme); } } diff --git a/src/state/context-mixin.ts b/src/state/context-mixin.ts new file mode 100644 index 0000000000..79486e1756 --- /dev/null +++ b/src/state/context-mixin.ts @@ -0,0 +1,97 @@ +import { ContextProvider } from "@lit-labs/context"; +import { + areasContext, + configContext, + devicesContext, + entitiesContext, + localeContext, + localizeContext, + panelsContext, + selectedThemeContext, + statesContext, + themesContext, + userContext, + userDataContext, +} from "../data/context"; +import { Constructor, HomeAssistant } from "../types"; +import { HassBaseEl } from "./hass-base-mixin"; + +export const contextMixin = >( + superClass: T +) => + class extends superClass { + private __contextProviders: Record< + string, + ContextProvider | undefined + > = { + states: new ContextProvider( + this, + statesContext, + this.hass ? this.hass.states : this._pendingHass.states + ), + entities: new ContextProvider( + this, + entitiesContext, + this.hass ? this.hass.entities : this._pendingHass.entities + ), + devices: new ContextProvider( + this, + devicesContext, + this.hass ? this.hass.devices : this._pendingHass.devices + ), + areas: new ContextProvider( + this, + areasContext, + this.hass ? this.hass.areas : this._pendingHass.areas + ), + localize: new ContextProvider( + this, + localizeContext, + this.hass ? this.hass.localize : this._pendingHass.localize + ), + locale: new ContextProvider( + this, + localeContext, + this.hass ? this.hass.locale : this._pendingHass.locale + ), + config: new ContextProvider( + this, + configContext, + this.hass ? this.hass.config : this._pendingHass.config + ), + themes: new ContextProvider( + this, + themesContext, + this.hass ? this.hass.themes : this._pendingHass.themes + ), + selectedTheme: new ContextProvider( + this, + selectedThemeContext, + this.hass ? this.hass.selectedTheme : this._pendingHass.selectedTheme + ), + user: new ContextProvider( + this, + userContext, + this.hass ? this.hass.user : this._pendingHass.user + ), + userData: new ContextProvider( + this, + userDataContext, + this.hass ? this.hass.userData : this._pendingHass.userData + ), + panels: new ContextProvider( + this, + panelsContext, + this.hass ? this.hass.panels : this._pendingHass.panels + ), + }; + + protected _updateHass(obj: Partial) { + super._updateHass(obj); + for (const [key, value] of Object.entries(obj)) { + if (key in this.__contextProviders) { + this.__contextProviders[key]!.setValue(value); + } + } + } + }; diff --git a/src/state/hass-element.ts b/src/state/hass-element.ts index 7a24238c23..e6e5b9f67a 100644 --- a/src/state/hass-element.ts +++ b/src/state/hass-element.ts @@ -6,6 +6,7 @@ import DisconnectToastMixin from "./disconnect-toast-mixin"; import { hapticMixin } from "./haptic-mixin"; import { HassBaseEl } from "./hass-base-mixin"; import { loggingMixin } from "./logging-mixin"; +import { contextMixin } from "./context-mixin"; import MoreInfoMixin from "./more-info-mixin"; import NotificationMixin from "./notification-mixin"; import { panelTitleMixin } from "./panel-title-mixin"; @@ -31,4 +32,5 @@ export class HassElement extends ext(HassBaseEl, [ hapticMixin, panelTitleMixin, loggingMixin, + contextMixin, ]) {} diff --git a/yarn.lock b/yarn.lock index cd7b18976a..63f90d3ca4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1955,6 +1955,16 @@ __metadata: languageName: node linkType: hard +"@lit-labs/context@npm:0.2.0": + version: 0.2.0 + resolution: "@lit-labs/context@npm:0.2.0" + dependencies: + "@lit/reactive-element": ^1.5.0 + lit: ^2.5.0 + checksum: 0b3d803ba81683d9650ba384e5f138656ecd52d6a54448535e867e0a0ba0cb23e4526ec52e82ed657e9c3598a103c0e8b164bfe927222467e349fb070c770af3 + languageName: node + linkType: hard + "@lit-labs/motion@npm:1.0.3": version: 1.0.3 resolution: "@lit-labs/motion@npm:1.0.3" @@ -1982,7 +1992,7 @@ __metadata: languageName: node linkType: hard -"@lit/reactive-element@npm:^1.3.0, @lit/reactive-element@npm:^1.6.0": +"@lit/reactive-element@npm:^1.3.0, @lit/reactive-element@npm:^1.5.0, @lit/reactive-element@npm:^1.6.0": version: 1.6.1 resolution: "@lit/reactive-element@npm:1.6.1" dependencies: @@ -9416,6 +9426,7 @@ __metadata: "@fullcalendar/timegrid": 6.1.5 "@koa/cors": 4.0.0 "@lezer/highlight": 1.1.3 + "@lit-labs/context": 0.2.0 "@lit-labs/motion": 1.0.3 "@lit-labs/virtualizer": 1.0.1 "@material/chips": =14.0.0-canary.53b3cad2f.0