diff --git a/src/components/ha-bar-slider.ts b/src/components/ha-bar-slider.ts index a0169918ab..2f7eb4e542 100644 --- a/src/components/ha-bar-slider.ts +++ b/src/components/ha-bar-slider.ts @@ -65,9 +65,6 @@ export class HaBarSlider extends LitElement { @property({ type: Number }) public max = 100; - @property() - public label?: string; - private _mc?: HammerManager; @property({ type: Boolean, reflect: true }) diff --git a/src/components/ha-cover-controls.ts b/src/components/ha-cover-controls.ts index 95b93ac039..546cdaaf2a 100644 --- a/src/components/ha-cover-controls.ts +++ b/src/components/ha-cover-controls.ts @@ -5,14 +5,12 @@ import { classMap } from "lit/directives/class-map"; import { computeCloseIcon, computeOpenIcon } from "../common/entity/cover_icon"; import { supportsFeature } from "../common/entity/supports-feature"; import { + canClose, + canOpen, + canStop, CoverEntity, CoverEntityFeature, - isClosing, - isFullyClosed, - isFullyOpen, - isOpening, } from "../data/cover"; -import { UNAVAILABLE } from "../data/entity"; import type { HomeAssistant } from "../types"; import "./ha-icon-button"; @@ -37,7 +35,7 @@ class HaCoverControls extends LitElement { "ui.dialogs.more_info_control.cover.open_cover" )} @click=${this._onOpenTap} - .disabled=${this._computeOpenDisabled()} + .disabled=${!canOpen(this.stateObj)} .path=${computeOpenIcon(this.stateObj)} > @@ -50,7 +48,7 @@ class HaCoverControls extends LitElement { )} .path=${mdiStop} @click=${this._onStopTap} - .disabled=${this.stateObj.state === UNAVAILABLE} + .disabled=${!canStop(this.stateObj)} > @@ -68,27 +66,6 @@ class HaCoverControls extends LitElement { `; } - private _computeOpenDisabled(): boolean { - if (this.stateObj.state === UNAVAILABLE) { - return true; - } - const assumedState = this.stateObj.attributes.assumed_state === true; - return ( - (isFullyOpen(this.stateObj) || isOpening(this.stateObj)) && !assumedState - ); - } - - private _computeClosedDisabled(): boolean { - if (this.stateObj.state === UNAVAILABLE) { - return true; - } - const assumedState = this.stateObj.attributes.assumed_state === true; - return ( - (isFullyClosed(this.stateObj) || isClosing(this.stateObj)) && - !assumedState - ); - } - private _onOpenTap(ev): void { ev.stopPropagation(); this.hass.callService("cover", "open_cover", { diff --git a/src/components/ha-cover-tilt-controls.ts b/src/components/ha-cover-tilt-controls.ts index 544e1dbe49..7bea01ba93 100644 --- a/src/components/ha-cover-tilt-controls.ts +++ b/src/components/ha-cover-tilt-controls.ts @@ -4,12 +4,12 @@ import { customElement, property } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { supportsFeature } from "../common/entity/supports-feature"; import { + canCloseTilt, + canOpenTilt, + canStopTilt, CoverEntity, CoverEntityFeature, - isFullyClosedTilt, - isFullyOpenTilt, } from "../data/cover"; -import { UNAVAILABLE } from "../data/entity"; import { HomeAssistant } from "../types"; import "./ha-icon-button"; @@ -36,7 +36,7 @@ class HaCoverTiltControls extends LitElement { )} .path=${mdiArrowTopRight} @click=${this._onOpenTiltTap} - .disabled=${this._computeOpenDisabled()} + .disabled=${!canOpenTilt(this.stateObj)} > `; } - private _computeOpenDisabled(): boolean { - if (this.stateObj.state === UNAVAILABLE) { - return true; - } - const assumedState = this.stateObj.attributes.assumed_state === true; - return isFullyOpenTilt(this.stateObj) && !assumedState; - } - - private _computeClosedDisabled(): boolean { - if (this.stateObj.state === UNAVAILABLE) { - return true; - } - const assumedState = this.stateObj.attributes.assumed_state === true; - return isFullyClosedTilt(this.stateObj) && !assumedState; - } - private _onOpenTiltTap(ev): void { ev.stopPropagation(); this.hass.callService("cover", "open_cover_tilt", { diff --git a/src/components/tile/ha-tile-button.ts b/src/components/tile/ha-tile-button.ts new file mode 100644 index 0000000000..ef56e96e44 --- /dev/null +++ b/src/components/tile/ha-tile-button.ts @@ -0,0 +1,128 @@ +import { Ripple } from "@material/mwc-ripple"; +import { RippleHandlers } from "@material/mwc-ripple/ripple-handlers"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { + customElement, + eventOptions, + property, + queryAsync, + state, +} from "lit/decorators"; +import { ifDefined } from "lit/directives/if-defined"; +import "../ha-icon"; +import "../ha-svg-icon"; + +@customElement("ha-tile-button") +export class HaTileButton extends LitElement { + @property({ type: Boolean, reflect: true }) disabled = false; + + @property() public label?: string; + + @queryAsync("mwc-ripple") private _ripple!: Promise; + + @state() private _shouldRenderRipple = false; + + protected render(): TemplateResult { + return html` + + `; + } + + private _rippleHandlers: RippleHandlers = new RippleHandlers(() => { + this._shouldRenderRipple = true; + return this._ripple; + }); + + @eventOptions({ passive: true }) + private handleRippleActivate(evt?: Event) { + this._rippleHandlers.startPress(evt); + } + + private handleRippleDeactivate() { + this._rippleHandlers.endPress(); + } + + private handleRippleMouseEnter() { + this._rippleHandlers.startHover(); + } + + private handleRippleMouseLeave() { + this._rippleHandlers.endHover(); + } + + private handleRippleFocus() { + this._rippleHandlers.startFocus(); + } + + private handleRippleBlur() { + this._rippleHandlers.endFocus(); + } + + static get styles(): CSSResultGroup { + return css` + :host { + --icon-color: rgb(var(--color, var(--rgb-primary-text-color))); + --bg-color: rgba(var(--color, var(--rgb-disabled-color)), 0.2); + --mdc-ripple-color: rgba(var(--color, var(--rgb-disabled-color))); + width: 40px; + height: 40px; + -webkit-tap-highlight-color: transparent; + } + .button { + overflow: hidden; + position: relative; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + border-radius: 12px; + border: none; + background-color: var(--bg-color); + transition: background-color 280ms ease-in-out, transform 180ms ease-out; + margin: 0; + padding: 0; + box-sizing: border-box; + line-height: 0; + outline: none; + } + .button ::slotted(*) { + --mdc-icon-size: 20px; + color: var(--icon-color); + pointer-events: none; + } + .button:disabled { + cursor: not-allowed; + background-color: rgba(var(--rgb-disabled-color), 0.2); + } + .button:disabled ::slotted(*) { + color: rgb(var(--rgb-disabled-color)); + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-tile-button": HaTileButton; + } +} diff --git a/src/data/cover.ts b/src/data/cover.ts index 7a7bab7ad2..ee45ed5c81 100644 --- a/src/data/cover.ts +++ b/src/data/cover.ts @@ -3,6 +3,7 @@ import { HassEntityBase, } from "home-assistant-js-websocket"; import { supportsFeature } from "../common/entity/supports-feature"; +import { UNAVAILABLE } from "./entity"; export const enum CoverEntityFeature { OPEN = 1, @@ -57,6 +58,46 @@ export function isTiltOnly(stateObj: CoverEntity) { return supportsTilt && !supportsCover; } +export function canOpen(stateObj: CoverEntity) { + if (stateObj.state === UNAVAILABLE) { + return false; + } + const assumedState = stateObj.attributes.assumed_state === true; + return (!isFullyOpen(stateObj) && !isOpening(stateObj)) || assumedState; +} + +export function canClose(stateObj: CoverEntity): boolean { + if (stateObj.state === UNAVAILABLE) { + return false; + } + const assumedState = stateObj.attributes.assumed_state === true; + return (!isFullyClosed(stateObj) && !isClosing(stateObj)) || assumedState; +} + +export function canStop(stateObj: CoverEntity): boolean { + return stateObj.state !== UNAVAILABLE; +} + +export function canOpenTilt(stateObj: CoverEntity): boolean { + if (stateObj.state === UNAVAILABLE) { + return false; + } + const assumedState = stateObj.attributes.assumed_state === true; + return !isFullyOpenTilt(stateObj) || assumedState; +} + +export function canCloseTilt(stateObj: CoverEntity): boolean { + if (stateObj.state === UNAVAILABLE) { + return false; + } + const assumedState = stateObj.attributes.assumed_state === true; + return !isFullyClosedTilt(stateObj) || assumedState; +} + +export function canStopTilt(stateObj: CoverEntity): boolean { + return stateObj.state !== UNAVAILABLE; +} + interface CoverEntityAttributes extends HassEntityAttributeBase { current_position?: number; current_tilt_position?: number; diff --git a/src/panels/lovelace/cards/hui-tile-card.ts b/src/panels/lovelace/cards/hui-tile-card.ts index fdf35d1506..3d8867b821 100644 --- a/src/panels/lovelace/cards/hui-tile-card.ts +++ b/src/panels/lovelace/cards/hui-tile-card.ts @@ -1,7 +1,7 @@ import { memoize } from "@fullcalendar/common"; import { mdiHelp } from "@mdi/js"; import { HassEntity } from "home-assistant-js-websocket"; -import { css, CSSResultGroup, html, LitElement } from "lit"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; import { styleMap } from "lit/directives/style-map"; import { computeRgbColor } from "../../../common/color/compute-color"; @@ -26,7 +26,11 @@ import { actionHandler } from "../common/directives/action-handler-directive"; import { findEntities } from "../common/find-entities"; import { handleAction } from "../common/handle-action"; import "../components/hui-timestamp-display"; -import { LovelaceCard, LovelaceCardEditor } from "../types"; +import { createTileExtraElement } from "../create-element/create-tile-extra-element"; +import { supportsTileExtra } from "../tile-extra/tile-extras"; +import { LovelaceTileExtraConfig } from "../tile-extra/types"; +import { LovelaceCard, LovelaceCardEditor, LovelaceTileExtra } from "../types"; +import { HuiErrorCard } from "./hui-error-card"; import { computeTileBadge } from "./tile/badges/tile-badge"; import { ThermostatCardConfig, TileCardConfig } from "./types"; @@ -69,8 +73,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard { throw new Error("Specify an entity"); } - const supportToggle = - config.entity && DOMAINS_TOGGLE.has(computeDomain(config.entity)); + const supportToggle = DOMAINS_TOGGLE.has(computeDomain(config.entity)); this._config = { tap_action: { @@ -146,14 +149,14 @@ export class HuiTileCard extends LitElement implements LovelaceCard { return stateColor; }); - render() { + protected render(): TemplateResult { if (!this._config || !this.hass) { return html``; } const entityId = this._config.entity; - const entity = entityId ? this.hass.states[entityId] : undefined; + const stateObj = entityId ? this.hass.states[entityId] : undefined; - if (!entity) { + if (!stateObj) { return html`
@@ -169,36 +172,40 @@ export class HuiTileCard extends LitElement implements LovelaceCard { `; } - const domain = computeDomain(entity.entity_id); + const domain = computeDomain(stateObj.entity_id); - const icon = this._config.icon || entity.attributes.icon; - const iconPath = stateIconPath(entity); + const icon = this._config.icon || stateObj.attributes.icon; + const iconPath = stateIconPath(stateObj); - const name = this._config.name || entity.attributes.friendly_name; + const name = this._config.name || stateObj.attributes.friendly_name; const stateDisplay = - (entity.attributes.device_class === SENSOR_DEVICE_CLASS_TIMESTAMP || + (stateObj.attributes.device_class === SENSOR_DEVICE_CLASS_TIMESTAMP || TIMESTAMP_STATE_DOMAINS.includes(domain)) && - !UNAVAILABLE_STATES.includes(entity.state) + !UNAVAILABLE_STATES.includes(stateObj.state) ? html` ` - : computeStateDisplay(this.hass!.localize, entity, this.hass.locale); + : computeStateDisplay(this.hass!.localize, stateObj, this.hass.locale); - const color = this._computeStateColor(entity, this._config.color); + const color = this._computeStateColor(stateObj, this._config.color); const style = { "--tile-color": color, }; const imageUrl = this._config.show_entity_picture - ? this._getImageUrl(entity) + ? this._getImageUrl(stateObj) : undefined; - const badge = computeTileBadge(entity, this.hass); + const badge = computeTileBadge(stateObj, this.hass); + + const supportedExtras = this._config.extras?.filter((extra) => + supportsTileExtra(stateObj, extra.type) + ); return html` @@ -246,15 +253,54 @@ export class HuiTileCard extends LitElement implements LovelaceCard { .actionHandler=${actionHandler()} >
+ ${supportedExtras?.length + ? html` +
+ ${supportedExtras.map((extraConf) => + this.renderExtra(extraConf, stateObj) + )} +
+ ` + : null}
`; } + private _extrasElements = new WeakMap< + LovelaceTileExtraConfig, + LovelaceTileExtra | HuiErrorCard + >(); + + private _getExtraElement(extra: LovelaceTileExtraConfig) { + if (!this._extrasElements.has(extra)) { + const element = createTileExtraElement(extra); + this._extrasElements.set(extra, element); + return element; + } + + return this._extrasElements.get(extra)!; + } + + private renderExtra( + extraConf: LovelaceTileExtraConfig, + stateObj: HassEntity + ): TemplateResult { + const element = this._getExtraElement(extraConf); + + if (this.hass) { + element.hass = this.hass; + (element as LovelaceTileExtra).stateObj = stateObj; + } + + return html`
${element}
`; + } + static get styles(): CSSResultGroup { return css` :host { --tile-color: var(--rgb-disabled-color); --tile-tap-padding: 6px; + -webkit-tap-highlight-color: transparent; } ha-card { height: 100%; diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index 75f1d2bdee..b7dc898902 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -11,6 +11,7 @@ import { } from "../entity-rows/types"; import { LovelaceHeaderFooterConfig } from "../header-footer/types"; import { HaDurationData } from "../../../components/ha-duration-input"; +import { LovelaceTileExtraConfig } from "../tile-extra/types"; export interface AlarmPanelCardConfig extends LovelaceCardConfig { entity: string; @@ -501,4 +502,5 @@ export interface TileCardConfig extends LovelaceCardConfig { show_entity_picture?: string; tap_action?: ActionConfig; icon_tap_action?: ActionConfig; + extras?: LovelaceTileExtraConfig[]; } diff --git a/src/panels/lovelace/create-element/create-element-base.ts b/src/panels/lovelace/create-element/create-element-base.ts index d236d0faf5..df2db56ba5 100644 --- a/src/panels/lovelace/create-element/create-element-base.ts +++ b/src/panels/lovelace/create-element/create-element-base.ts @@ -11,6 +11,7 @@ import type { ErrorCardConfig } from "../cards/types"; import { LovelaceElement, LovelaceElementConfig } from "../elements/types"; import { LovelaceRow, LovelaceRowConfig } from "../entity-rows/types"; import { LovelaceHeaderFooterConfig } from "../header-footer/types"; +import { LovelaceTileExtraConfig } from "../tile-extra/types"; import { LovelaceBadge, LovelaceCard, @@ -18,6 +19,8 @@ import { LovelaceHeaderFooter, LovelaceHeaderFooterConstructor, LovelaceRowConstructor, + LovelaceTileExtra, + LovelaceTileExtraConstructor, } from "../types"; const TIMEOUT = 2000; @@ -53,6 +56,11 @@ interface CreateElementConfigTypes { element: LovelaceViewElement; constructor: unknown; }; + "tile-extra": { + config: LovelaceTileExtraConfig; + element: LovelaceTileExtra; + constructor: LovelaceTileExtraConstructor; + }; } export const createErrorCardElement = (config: ErrorCardConfig) => { diff --git a/src/panels/lovelace/create-element/create-tile-extra-element.ts b/src/panels/lovelace/create-element/create-tile-extra-element.ts new file mode 100644 index 0000000000..02fac9ba2c --- /dev/null +++ b/src/panels/lovelace/create-element/create-tile-extra-element.ts @@ -0,0 +1,18 @@ +import { LovelaceTileExtraConfig } from "../tile-extra/types"; +import { + createLovelaceElement, + getLovelaceElementClass, +} from "./create-element-base"; +import "../tile-extra/hui-cover-open-close-tile-extra"; +import "../tile-extra/hui-cover-tilt-tile-extra"; + +const TYPES: Set = new Set([ + "cover-open-close", + "cover-tilt", +]); + +export const createTileExtraElement = (config: LovelaceTileExtraConfig) => + createLovelaceElement("tile-extra", config, TYPES); + +export const getTileExtraElementClass = (type: string) => + getLovelaceElementClass(type, "tile-extra", TYPES); diff --git a/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts index d5a4b84f43..c86b264e69 100644 --- a/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts @@ -3,9 +3,18 @@ import { HassEntity } from "home-assistant-js-websocket"; import { css, html, LitElement, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; -import { assert, assign, boolean, object, optional, string } from "superstruct"; +import { + any, + array, + assert, + assign, + boolean, + object, + optional, + string, +} from "superstruct"; import { THEME_COLORS } from "../../../../common/color/compute-color"; -import { fireEvent } from "../../../../common/dom/fire_event"; +import { fireEvent, HASSDomEvent } from "../../../../common/dom/fire_event"; import { computeDomain } from "../../../../common/entity/compute_domain"; import { domainIcon } from "../../../../common/entity/domain_icon"; import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter"; @@ -13,9 +22,14 @@ import "../../../../components/ha-form/ha-form"; import type { SchemaUnion } from "../../../../components/ha-form/types"; import type { HomeAssistant } from "../../../../types"; import type { TileCardConfig } from "../../cards/types"; +import { LovelaceTileExtraConfig } from "../../tile-extra/types"; import type { LovelaceCardEditor } from "../../types"; +import "../hui-sub-element-editor"; import { actionConfigStruct } from "../structs/action-struct"; import { baseLovelaceCardConfig } from "../structs/base-card-struct"; +import { EditSubElementEvent, SubElementEditorConfig } from "../types"; +import { configElementStyle } from "./config-elements-style"; +import "./hui-tile-card-extras-editor"; const cardConfigStruct = assign( baseLovelaceCardConfig, @@ -27,6 +41,7 @@ const cardConfigStruct = assign( show_entity_picture: optional(boolean()), tap_action: optional(actionConfigStruct), icon_tap_action: optional(actionConfigStruct), + extras: optional(array(any())), }) ); @@ -39,13 +54,15 @@ export class HuiTileCardEditor @state() private _config?: TileCardConfig; + @state() private _subElementEditorConfig?: SubElementEditorConfig; + public setConfig(config: TileCardConfig): void { assert(config, cardConfigStruct); this._config = config; } private _schema = memoizeOne( - (entity: string, icon?: string, entityState?: HassEntity) => + (entity: string, icon?: string, stateObj?: HassEntity) => [ { name: "entity", selector: { entity: {} } }, { @@ -65,10 +82,10 @@ export class HuiTileCardEditor name: "icon", selector: { icon: { - placeholder: icon || entityState?.attributes.icon, + placeholder: icon || stateObj?.attributes.icon, fallbackPath: - !icon && !entityState?.attributes.icon && entityState - ? domainIcon(computeDomain(entity), entityState) + !icon && !stateObj?.attributes.icon && stateObj + ? domainIcon(computeDomain(entity), stateObj) : undefined, }, }, @@ -132,17 +149,33 @@ export class HuiTileCardEditor return html``; } - const entity = this.hass.states[this._config.entity ?? ""] as + const stateObj = this.hass.states[this._config.entity ?? ""] as | HassEntity | undefined; - const schema = this._schema(this._config.entity, this._config.icon, entity); + const schema = this._schema( + this._config.entity, + this._config.icon, + stateObj + ); const data = { color: "default", ...this._config, }; + if (this._subElementEditorConfig) { + return html` + + + `; + } + return html` + `; } private _valueChanged(ev: CustomEvent): void { - const config = { + ev.stopPropagation(); + if (!this._config || !this.hass) { + return; + } + + const config: TileCardConfig = { + extras: this._config.extras, ...ev.detail.value, }; if (ev.detail.value.color === "default") { @@ -164,6 +210,62 @@ export class HuiTileCardEditor fireEvent(this, "config-changed", { config }); } + private _extrasChanged(ev: CustomEvent) { + ev.stopPropagation(); + if (!this._config || !this.hass) { + return; + } + + const extras = ev.detail.extras as LovelaceTileExtraConfig[]; + const config: TileCardConfig = { + ...this._config, + extras, + }; + + if (extras.length === 0) { + delete config.extras; + } + + fireEvent(this, "config-changed", { config }); + } + + private subElementChanged(ev: CustomEvent): void { + ev.stopPropagation(); + if (!this._config || !this.hass) { + return; + } + + const value = ev.detail.config; + + const newConfigExtras = this._config!.extras + ? [...this._config!.extras] + : []; + + if (!value) { + newConfigExtras.splice(this._subElementEditorConfig!.index!, 1); + this._goBack(); + } else { + newConfigExtras[this._subElementEditorConfig!.index!] = value; + } + + this._config = { ...this._config!, extras: newConfigExtras }; + + this._subElementEditorConfig = { + ...this._subElementEditorConfig!, + elementConfig: value, + }; + + fireEvent(this, "config-changed", { config: this._config }); + } + + private _editDetailElement(ev: HASSDomEvent): void { + this._subElementEditorConfig = ev.detail.subElementConfig; + } + + private _goBack(): void { + this._subElementEditorConfig = undefined; + } + private _computeLabelCallback = ( schema: SchemaUnion> ) => { @@ -183,12 +285,19 @@ export class HuiTileCardEditor }; static get styles() { - return css` - .container { - display: flex; - flex-direction: column; - } - `; + return [ + configElementStyle, + css` + .container { + display: flex; + flex-direction: column; + } + ha-form { + display: block; + margin-bottom: 24px; + } + `, + ]; } } diff --git a/src/panels/lovelace/editor/config-elements/hui-tile-card-extras-editor.ts b/src/panels/lovelace/editor/config-elements/hui-tile-card-extras-editor.ts new file mode 100644 index 0000000000..3a69e336de --- /dev/null +++ b/src/panels/lovelace/editor/config-elements/hui-tile-card-extras-editor.ts @@ -0,0 +1,345 @@ +import { + mdiDelete, + mdiDrag, + mdiListBox, + mdiPencil, + mdiPlus, + mdiWindowShutter, +} from "@mdi/js"; +import { HassEntity } from "home-assistant-js-websocket"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators"; +import { repeat } from "lit/directives/repeat"; +import type { SortableEvent } from "sortablejs"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { stopPropagation } from "../../../../common/dom/stop_propagation"; +import "../../../../components/entity/ha-entity-picker"; +import "../../../../components/ha-icon-button"; +import "../../../../components/ha-svg-icon"; +import { sortableStyles } from "../../../../resources/ha-sortable-style"; +import { + loadSortable, + SortableInstance, +} from "../../../../resources/sortable.ondemand"; +import { HomeAssistant } from "../../../../types"; +import { getTileExtraElementClass } from "../../create-element/create-tile-extra-element"; +import { + isTileExtraEditable, + supportsTileExtra, +} from "../../tile-extra/tile-extras"; +import { LovelaceTileExtraConfig } from "../../tile-extra/types"; + +const EXTRAS_TYPE: LovelaceTileExtraConfig["type"][] = [ + "cover-open-close", + "cover-tilt", +]; + +declare global { + interface HASSDomEvents { + "extras-changed": { + extras: LovelaceTileExtraConfig[]; + }; + } +} + +@customElement("hui-tile-card-extras-editor") +export class HuiTileCardExtrasEditor extends LitElement { + @property({ attribute: false }) public hass?: HomeAssistant; + + @property({ attribute: false }) public stateObj?: HassEntity; + + @property({ attribute: false }) + public extras?: LovelaceTileExtraConfig[]; + + @property() public label?: string; + + private _extraKeys = new WeakMap(); + + private _sortable?: SortableInstance; + + public disconnectedCallback() { + this._destroySortable(); + } + + private _getKey(extra: LovelaceTileExtraConfig) { + if (!this._extraKeys.has(extra)) { + this._extraKeys.set(extra, Math.random().toString()); + } + + return this._extraKeys.get(extra)!; + } + + private get _supportedExtraTypes() { + if (!this.stateObj) return []; + + return EXTRAS_TYPE.filter((type) => + supportsTileExtra(this.stateObj!, type) + ); + } + + protected render(): TemplateResult { + if (!this.extras || !this.hass) { + return html``; + } + + return html` + +

+ + ${this.hass!.localize( + "ui.panel.lovelace.editor.card.tile.extras.name" + )} +

+
+ ${this._supportedExtraTypes.length === 0 && this.extras.length === 0 + ? html` + + ${this.hass!.localize( + "ui.panel.lovelace.editor.card.tile.extras.no_compatible_available" + )} + + ` + : null} +
+ ${repeat( + this.extras, + (extraConf) => this._getKey(extraConf), + (extraConf, index) => html` +
+
+ +
+
+
+ + ${this.hass!.localize( + `ui.panel.lovelace.editor.card.tile.extras.types.${extraConf.type}.label` + )} + + ${this.stateObj && + !supportsTileExtra(this.stateObj, extraConf.type) + ? html` + ${this.hass!.localize( + "ui.panel.lovelace.editor.card.tile.extras.not_compatible" + )} + ` + : null} +
+
+ ${isTileExtraEditable(extraConf.type) + ? html`` + : null} + +
+ ` + )} +
+ ${this._supportedExtraTypes.length > 0 + ? html` + + + + + ${this._supportedExtraTypes.map( + (extraType) => html` + + ${this.hass!.localize( + `ui.panel.lovelace.editor.card.tile.extras.types.${extraType}.label` + )} + ` + )} + + ` + : null} +
+
+ `; + } + + protected firstUpdated(): void { + this._createSortable(); + } + + private async _createSortable() { + const Sortable = await loadSortable(); + this._sortable = new Sortable(this.shadowRoot!.querySelector(".extras")!, { + animation: 150, + fallbackClass: "sortable-fallback", + handle: ".handle", + onChoose: (evt: SortableEvent) => { + (evt.item as any).placeholder = + document.createComment("sort-placeholder"); + evt.item.after((evt.item as any).placeholder); + }, + onEnd: (evt: SortableEvent) => { + // put back in original location + if ((evt.item as any).placeholder) { + (evt.item as any).placeholder.replaceWith(evt.item); + delete (evt.item as any).placeholder; + } + this._rowMoved(evt); + }, + }); + } + + private _destroySortable() { + this._sortable?.destroy(); + this._sortable = undefined; + } + + private async _addExtra(ev: CustomEvent): Promise { + const index = ev.detail.index as number; + + if (index == null) return; + + const value = this._supportedExtraTypes[index]; + const elClass = await getTileExtraElementClass(value); + + let newExtra: LovelaceTileExtraConfig; + if (elClass && elClass.getStubConfig) { + newExtra = await elClass.getStubConfig(this.hass!); + } else { + newExtra = { type: value } as LovelaceTileExtraConfig; + } + const newConfigExtra = this.extras!.concat(newExtra); + fireEvent(this, "extras-changed", { extras: newConfigExtra }); + } + + private _rowMoved(ev: SortableEvent): void { + if (ev.oldIndex === ev.newIndex) { + return; + } + + const newExtras = this.extras!.concat(); + + newExtras.splice(ev.newIndex!, 0, newExtras.splice(ev.oldIndex!, 1)[0]); + + fireEvent(this, "extras-changed", { extras: newExtras }); + } + + private _removeExtra(ev: CustomEvent): void { + const index = (ev.currentTarget as any).index; + const newExtras = this.extras!.concat(); + + newExtras.splice(index, 1); + + fireEvent(this, "extras-changed", { extras: newExtras }); + } + + private _editExtra(ev: CustomEvent): void { + const index = (ev.currentTarget as any).index; + fireEvent(this, "edit-detail-element", { + subElementConfig: { + index, + type: "tile-extra", + elementConfig: this.extras![index], + }, + }); + } + + static get styles(): CSSResultGroup { + return [ + sortableStyles, + css` + :host { + display: flex !important; + flex-direction: column; + } + .content { + padding: 12px; + } + ha-expansion-panel { + display: block; + --expansion-panel-content-padding: 0; + border-radius: 6px; + } + h3 { + margin: 0; + font-size: inherit; + font-weight: inherit; + } + ha-svg-icon, + ha-icon { + color: var(--secondary-text-color); + } + ha-button-menu { + margin-top: 8px; + } + .extra { + display: flex; + align-items: center; + } + .extra .handle { + padding-right: 8px; + cursor: move; + padding-inline-end: 8px; + padding-inline-start: initial; + direction: var(--direction); + } + .extra .handle > * { + pointer-events: none; + } + + .extra-content { + height: 60px; + font-size: 16px; + display: flex; + align-items: center; + justify-content: space-between; + flex-grow: 1; + } + + .extra-content div { + display: flex; + flex-direction: column; + } + + .remove-icon, + .edit-icon { + --mdc-icon-button-size: 36px; + color: var(--secondary-text-color); + } + + .secondary { + font-size: 12px; + color: var(--secondary-text-color); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-tile-card-extras-editor": HuiTileCardExtrasEditor; + } +} diff --git a/src/panels/lovelace/editor/hui-element-editor.ts b/src/panels/lovelace/editor/hui-element-editor.ts index 0f08a36f94..bfa97f75bd 100644 --- a/src/panels/lovelace/editor/hui-element-editor.ts +++ b/src/panels/lovelace/editor/hui-element-editor.ts @@ -27,9 +27,14 @@ import type { LovelaceGenericElementEditor } from "../types"; import "./config-elements/hui-generic-entity-row-editor"; import { GUISupportError } from "./gui-support-error"; import { EditSubElementEvent, GUIModeChangedEvent } from "./types"; +import { LovelaceTileExtraConfig } from "../tile-extra/types"; export interface ConfigChangedEvent { - config: LovelaceCardConfig | LovelaceRowConfig | LovelaceHeaderFooterConfig; + config: + | LovelaceCardConfig + | LovelaceRowConfig + | LovelaceHeaderFooterConfig + | LovelaceTileExtraConfig; error?: string; guiModeAvailable?: boolean; } @@ -44,7 +49,11 @@ declare global { export interface UIConfigChangedEvent extends Event { detail: { - config: LovelaceCardConfig | LovelaceRowConfig | LovelaceHeaderFooterConfig; + config: + | LovelaceCardConfig + | LovelaceRowConfig + | LovelaceHeaderFooterConfig + | LovelaceTileExtraConfig; }; } diff --git a/src/panels/lovelace/editor/hui-sub-element-editor.ts b/src/panels/lovelace/editor/hui-sub-element-editor.ts index eeae816645..2d46415ffa 100644 --- a/src/panels/lovelace/editor/hui-sub-element-editor.ts +++ b/src/panels/lovelace/editor/hui-sub-element-editor.ts @@ -1,7 +1,7 @@ import "@material/mwc-button"; import { mdiArrowLeft } from "@mdi/js"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; -import { customElement, property, state, query } from "lit/decorators"; +import { customElement, property, query, state } from "lit/decorators"; import { fireEvent, HASSDomEvent } from "../../../common/dom/fire_event"; import "../../../components/ha-icon-button"; import type { HomeAssistant } from "../../../types"; @@ -10,6 +10,7 @@ import type { LovelaceHeaderFooterConfig } from "../header-footer/types"; import "./entity-row-editor/hui-row-element-editor"; import "./header-footer-editor/hui-header-footer-element-editor"; import type { HuiElementEditor } from "./hui-element-editor"; +import "./tile-extra/hui-tile-extra-element-editor"; import type { GUIModeChangedEvent, SubElementEditorConfig } from "./types"; declare global { @@ -79,6 +80,16 @@ export class HuiSubElementEditor extends LitElement { @GUImode-changed=${this._handleGUIModeChanged} > ` + : this.config.type === "tile-extra" + ? html` + + ` : ""} `; } diff --git a/src/panels/lovelace/editor/tile-extra/hui-tile-extra-element-editor.ts b/src/panels/lovelace/editor/tile-extra/hui-tile-extra-element-editor.ts new file mode 100644 index 0000000000..046a4d2d14 --- /dev/null +++ b/src/panels/lovelace/editor/tile-extra/hui-tile-extra-element-editor.ts @@ -0,0 +1,27 @@ +import { customElement } from "lit/decorators"; +import { getTileExtraElementClass } from "../../create-element/create-tile-extra-element"; +import { LovelaceTileExtraConfig } from "../../tile-extra/types"; +import type { LovelaceTileExtraEditor } from "../../types"; +import { HuiElementEditor } from "../hui-element-editor"; + +@customElement("hui-tile-extra-element-editor") +export class HuiTileExtraElementEditor extends HuiElementEditor { + protected async getConfigElement(): Promise< + LovelaceTileExtraEditor | undefined + > { + const elClass = await getTileExtraElementClass(this.configElementType!); + + // Check if a GUI editor exists + if (elClass && elClass.getConfigElement) { + return elClass.getConfigElement(); + } + + return undefined; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-tile-extra-element-editor": HuiTileExtraElementEditor; + } +} diff --git a/src/panels/lovelace/editor/types.ts b/src/panels/lovelace/editor/types.ts index 998c33b6a0..af2a62371c 100644 --- a/src/panels/lovelace/editor/types.ts +++ b/src/panels/lovelace/editor/types.ts @@ -6,6 +6,7 @@ import { } from "../../../data/lovelace"; import { EntityConfig, LovelaceRowConfig } from "../entity-rows/types"; import { LovelaceHeaderFooterConfig } from "../header-footer/types"; +import { LovelaceTileExtraConfig } from "../tile-extra/types"; export interface YamlChangedEvent extends Event { detail: { @@ -74,8 +75,11 @@ export interface CardPickTarget extends EventTarget { export interface SubElementEditorConfig { index?: number; - elementConfig?: LovelaceRowConfig | LovelaceHeaderFooterConfig; - type: "header" | "footer" | "row"; + elementConfig?: + | LovelaceRowConfig + | LovelaceHeaderFooterConfig + | LovelaceTileExtraConfig; + type: "header" | "footer" | "row" | "tile-extra"; } export interface EditSubElementEvent { diff --git a/src/panels/lovelace/tile-extra/hui-cover-open-close-tile-extra.ts b/src/panels/lovelace/tile-extra/hui-cover-open-close-tile-extra.ts new file mode 100644 index 0000000000..2b2f2fd02e --- /dev/null +++ b/src/panels/lovelace/tile-extra/hui-cover-open-close-tile-extra.ts @@ -0,0 +1,143 @@ +import { mdiStop } from "@mdi/js"; +import { HassEntity } from "home-assistant-js-websocket"; +import { css, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { + computeCloseIcon, + computeOpenIcon, +} from "../../../common/entity/cover_icon"; +import { supportsFeature } from "../../../common/entity/supports-feature"; +import "../../../components/tile/ha-tile-button"; +import { + canClose, + canOpen, + canStop, + CoverEntityFeature, +} from "../../../data/cover"; +import { HomeAssistant } from "../../../types"; +import { LovelaceTileExtra } from "../types"; +import { CoverOpenCloseTileExtraConfig } from "./types"; + +@customElement("hui-cover-open-close-tile-extra") +class HuiCoverOpenCloseTileExtra + extends LitElement + implements LovelaceTileExtra +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @property({ attribute: false }) public stateObj?: HassEntity; + + @state() private _config?: CoverOpenCloseTileExtraConfig; + + static getStubConfig(): CoverOpenCloseTileExtraConfig { + return { + type: "cover-open-close", + }; + } + + public setConfig(config: CoverOpenCloseTileExtraConfig): void { + if (!config) { + throw new Error("Invalid configuration"); + } + this._config = config; + } + + private _onOpenTap(ev): void { + ev.stopPropagation(); + this.hass!.callService("cover", "open_cover", { + entity_id: this.stateObj!.entity_id, + }); + } + + private _onCloseTap(ev): void { + ev.stopPropagation(); + this.hass!.callService("cover", "close_cover", { + entity_id: this.stateObj!.entity_id, + }); + } + + private _onStopTap(ev): void { + ev.stopPropagation(); + this.hass!.callService("cover", "stop_cover", { + entity_id: this.stateObj!.entity_id, + }); + } + + protected render(): TemplateResult { + if (!this._config || !this.hass || !this.stateObj) { + return html``; + } + + return html` +
+ ${supportsFeature(this.stateObj, CoverEntityFeature.OPEN) + ? html` + + + + ` + : null} + ${supportsFeature(this.stateObj, CoverEntityFeature.STOP) + ? html` + + ` + : null} + ${supportsFeature(this.stateObj, CoverEntityFeature.CLOSE) + ? html` + + + + ` + : undefined} +
+ `; + } + + static get styles() { + return css` + .container { + display: flex; + flex-direction: row; + padding: 0 12px 12px 12px; + width: auto; + } + ha-tile-button { + flex: 1; + } + ha-tile-button:not(:last-child) { + margin-right: 12px; + margin-inline-end: 12px; + margin-inline-start: initial; + direction: var(--direction); + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-cover-open-close-tile-extra": HuiCoverOpenCloseTileExtra; + } +} diff --git a/src/panels/lovelace/tile-extra/hui-cover-tilt-tile-extra.ts b/src/panels/lovelace/tile-extra/hui-cover-tilt-tile-extra.ts new file mode 100644 index 0000000000..af41d4c6a9 --- /dev/null +++ b/src/panels/lovelace/tile-extra/hui-cover-tilt-tile-extra.ts @@ -0,0 +1,132 @@ +import { mdiArrowBottomLeft, mdiArrowTopRight, mdiStop } from "@mdi/js"; +import { HassEntity } from "home-assistant-js-websocket"; +import { css, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { supportsFeature } from "../../../common/entity/supports-feature"; +import "../../../components/tile/ha-tile-button"; +import { + canCloseTilt, + canOpenTilt, + canStopTilt, + CoverEntityFeature, +} from "../../../data/cover"; +import { HomeAssistant } from "../../../types"; +import { LovelaceTileExtra } from "../types"; +import { CoverTiltTileExtraConfig } from "./types"; + +@customElement("hui-cover-tilt-tile-extra") +class HuiCoverTiltTileExtra extends LitElement implements LovelaceTileExtra { + @property({ attribute: false }) public hass?: HomeAssistant; + + @property({ attribute: false }) public stateObj?: HassEntity; + + @state() private _config?: CoverTiltTileExtraConfig; + + static getStubConfig(): CoverTiltTileExtraConfig { + return { + type: "cover-tilt", + }; + } + + public setConfig(config: CoverTiltTileExtraConfig): void { + if (!config) { + throw new Error("Invalid configuration"); + } + this._config = config; + } + + private _onOpenTap(ev): void { + ev.stopPropagation(); + this.hass!.callService("cover", "open_cover_tilt", { + entity_id: this.stateObj!.entity_id, + }); + } + + private _onCloseTap(ev): void { + ev.stopPropagation(); + this.hass!.callService("cover", "close_cover_tilt", { + entity_id: this.stateObj!.entity_id, + }); + } + + private _onStopTap(ev): void { + ev.stopPropagation(); + this.hass!.callService("cover", "stop_cover_tilt", { + entity_id: this.stateObj!.entity_id, + }); + } + + protected render(): TemplateResult { + if (!this._config || !this.hass || !this.stateObj) { + return html``; + } + + return html` +
+ ${supportsFeature(this.stateObj, CoverEntityFeature.OPEN_TILT) + ? html` + + + + ` + : null} + ${supportsFeature(this.stateObj, CoverEntityFeature.STOP_TILT) + ? html` + + ` + : null} + ${supportsFeature(this.stateObj, CoverEntityFeature.CLOSE_TILT) + ? html` + + + + ` + : undefined} +
+ `; + } + + static get styles() { + return css` + .container { + display: flex; + flex-direction: row; + padding: 0 12px 12px 12px; + width: auto; + } + ha-tile-button { + flex: 1; + } + ha-tile-button:not(:last-child) { + margin-right: 12px; + margin-inline-end: 12px; + margin-inline-start: initial; + direction: var(--direction); + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-cover-tilt-tile-extra": HuiCoverTiltTileExtra; + } +} diff --git a/src/panels/lovelace/tile-extra/tile-extras.ts b/src/panels/lovelace/tile-extra/tile-extras.ts new file mode 100644 index 0000000000..71c9d1b7c1 --- /dev/null +++ b/src/panels/lovelace/tile-extra/tile-extras.ts @@ -0,0 +1,34 @@ +import { HassEntity } from "home-assistant-js-websocket"; +import { computeDomain } from "../../../common/entity/compute_domain"; +import { supportsFeature } from "../../../common/entity/supports-feature"; +import { CoverEntityFeature } from "../../../data/cover"; +import { LovelaceTileExtraConfig } from "./types"; + +type TileExtraType = LovelaceTileExtraConfig["type"]; +export type SupportsTileExtra = (stateObj: HassEntity) => boolean; + +const TILE_EXTRAS_SUPPORT: Record = { + "cover-open-close": (stateObj) => + computeDomain(stateObj.entity_id) === "cover" && + (supportsFeature(stateObj, CoverEntityFeature.OPEN) || + supportsFeature(stateObj, CoverEntityFeature.CLOSE)), + "cover-tilt": (stateObj) => + computeDomain(stateObj.entity_id) === "cover" && + (supportsFeature(stateObj, CoverEntityFeature.OPEN_TILT) || + supportsFeature(stateObj, CoverEntityFeature.CLOSE_TILT)), +}; + +const TILE_EXTRAS_EDITABLE: Set = new Set([]); + +export const supportsTileExtra = ( + stateObj: HassEntity, + extra: TileExtraType +): boolean => { + const supportFunction = TILE_EXTRAS_SUPPORT[extra] as + | SupportsTileExtra + | undefined; + return !supportFunction || supportFunction(stateObj); +}; + +export const isTileExtraEditable = (extra: TileExtraType): boolean => + TILE_EXTRAS_EDITABLE.has(extra); diff --git a/src/panels/lovelace/tile-extra/types.ts b/src/panels/lovelace/tile-extra/types.ts new file mode 100644 index 0000000000..d2a9578fc1 --- /dev/null +++ b/src/panels/lovelace/tile-extra/types.ts @@ -0,0 +1,11 @@ +export interface CoverOpenCloseTileExtraConfig { + type: "cover-open-close"; +} + +export interface CoverTiltTileExtraConfig { + type: "cover-tilt"; +} + +export type LovelaceTileExtraConfig = + | CoverOpenCloseTileExtraConfig + | CoverTiltTileExtraConfig; diff --git a/src/panels/lovelace/types.ts b/src/panels/lovelace/types.ts index b2db755a67..bb656818d5 100644 --- a/src/panels/lovelace/types.ts +++ b/src/panels/lovelace/types.ts @@ -1,3 +1,4 @@ +import { HassEntity } from "home-assistant-js-websocket"; import { LovelaceBadgeConfig, LovelaceCardConfig, @@ -7,6 +8,7 @@ import { FrontendLocaleData } from "../../data/translation"; import { Constructor, HomeAssistant } from "../../types"; import { LovelaceRow, LovelaceRowConfig } from "./entity-rows/types"; import { LovelaceHeaderFooterConfig } from "./header-footer/types"; +import { LovelaceTileExtraConfig } from "./tile-extra/types"; declare global { // eslint-disable-next-line @@ -92,3 +94,19 @@ export interface LovelaceGenericElementEditor extends HTMLElement { setConfig(config: any): void; focusYamlEditor?: () => void; } + +export interface LovelaceTileExtra extends HTMLElement { + hass?: HomeAssistant; + stateObj?: HassEntity; + setConfig(config: LovelaceTileExtraConfig); +} + +export interface LovelaceTileExtraConstructor + extends Constructor { + getConfigElement?: () => LovelaceTileExtraEditor; + getStubConfig?: (hass: HomeAssistant) => LovelaceTileExtraConfig; +} + +export interface LovelaceTileExtraEditor extends LovelaceGenericElementEditor { + setConfig(config: LovelaceTileExtraConfig): void; +} diff --git a/src/translations/en.json b/src/translations/en.json index 4792ad0ee0..2c283f2c73 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -4210,7 +4210,23 @@ "actions": "Actions", "appearance": "Appearance", "default_color": "Default color (state)", - "show_entity_picture": "Show entity picture" + "show_entity_picture": "Show entity picture", + "extras": { + "name": "Extras", + "not_compatible": "Not compatible", + "no_compatible_available": "No compatible extras available for this entity", + "add": "Add extra", + "edit": "Edit extra", + "remove": "Remove extra", + "types": { + "cover-open-close": { + "label": "Cover open/close" + }, + "cover-tilt": { + "label": "Cover tilt" + } + } + } }, "vertical-stack": { "name": "Vertical Stack",