diff --git a/src/common/const.ts b/src/common/const.ts index 6ec749d662..40374552e0 100644 --- a/src/common/const.ts +++ b/src/common/const.ts @@ -42,6 +42,7 @@ export const FIXED_DOMAIN_ICONS = { remote: "hass:remote", scene: "hass:palette", script: "hass:script-text", + select: "hass:format-list-bulleted", sensor: "hass:eye", simple_alarm: "hass:bell", sun: "hass:white-balance-sunny", @@ -83,6 +84,7 @@ export const DOMAINS_WITH_CARD = [ "number", "scene", "script", + "select", "timer", "vacuum", "water_heater", @@ -121,6 +123,7 @@ export const DOMAINS_HIDE_MORE_INFO = [ "input_text", "number", "scene", + "select", ]; /** Domains that should have the history hidden in the more info dialog. */ diff --git a/src/data/select.ts b/src/data/select.ts new file mode 100644 index 0000000000..9d7058d8ce --- /dev/null +++ b/src/data/select.ts @@ -0,0 +1,25 @@ +import { + HassEntityAttributeBase, + HassEntityBase, +} from "home-assistant-js-websocket"; +import { HomeAssistant } from "../types"; + +interface SelectEntityAttributes extends HassEntityAttributeBase { + options: string[]; +} + +export interface SelectEntity extends HassEntityBase { + attributes: SelectEntityAttributes; +} + +export const setSelectOption = ( + hass: HomeAssistant, + entity: string, + option: string +) => + hass.callService( + "select", + "select_option", + { option }, + { entity_id: entity } + ); diff --git a/src/panels/lovelace/create-element/create-row-element.ts b/src/panels/lovelace/create-element/create-row-element.ts index 955f4741c9..f1b63c717c 100644 --- a/src/panels/lovelace/create-element/create-row-element.ts +++ b/src/panels/lovelace/create-element/create-row-element.ts @@ -37,6 +37,7 @@ const LAZY_LOAD_TYPES = { "input-text-entity": () => import("../entity-rows/hui-input-text-entity-row"), "lock-entity": () => import("../entity-rows/hui-lock-entity-row"), "number-entity": () => import("../entity-rows/hui-number-entity-row"), + "select-entity": () => import("../entity-rows/hui-select-entity-row"), "timer-entity": () => import("../entity-rows/hui-timer-entity-row"), conditional: () => import("../special-rows/hui-conditional-row"), "weather-entity": () => import("../entity-rows/hui-weather-entity-row"), @@ -68,6 +69,7 @@ const DOMAIN_TO_ELEMENT_TYPE = { remote: "toggle", scene: "scene", script: "script", + select: "select", sensor: "sensor", timer: "timer", switch: "toggle", diff --git a/src/panels/lovelace/entity-rows/hui-select-entity-row.ts b/src/panels/lovelace/entity-rows/hui-select-entity-row.ts new file mode 100644 index 0000000000..7133db16f8 --- /dev/null +++ b/src/panels/lovelace/entity-rows/hui-select-entity-row.ts @@ -0,0 +1,186 @@ +import "@polymer/paper-item/paper-item"; +import "@polymer/paper-listbox/paper-listbox"; +import { + css, + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, +} from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import { ifDefined } from "lit/directives/if-defined"; +import { DOMAINS_HIDE_MORE_INFO } from "../../../common/const"; +import { stopPropagation } from "../../../common/dom/stop_propagation"; +import { computeDomain } from "../../../common/entity/compute_domain"; +import { computeStateName } from "../../../common/entity/compute_state_name"; +import "../../../components/entity/state-badge"; +import "../../../components/ha-paper-dropdown-menu"; +import { UNAVAILABLE_STATES } from "../../../data/entity"; +import { forwardHaptic } from "../../../data/haptics"; +import { SelectEntity, setSelectOption } from "../../../data/select"; +import { ActionHandlerEvent } from "../../../data/lovelace"; +import { HomeAssistant } from "../../../types"; +import { EntitiesCardEntityConfig } from "../cards/types"; +import { actionHandler } from "../common/directives/action-handler-directive"; +import { handleAction } from "../common/handle-action"; +import { hasAction } from "../common/has-action"; +import { hasConfigOrEntityChanged } from "../common/has-changed"; +import { createEntityNotFoundWarning } from "../components/hui-warning"; +import { LovelaceRow } from "./types"; + +@customElement("hui-select-entity-row") +class HuiSelectEntityRow extends LitElement implements LovelaceRow { + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _config?: EntitiesCardEntityConfig; + + public setConfig(config: EntitiesCardEntityConfig): void { + if (!config || !config.entity) { + throw new Error("Entity must be specified"); + } + + this._config = config; + } + + protected shouldUpdate(changedProps: PropertyValues): boolean { + return hasConfigOrEntityChanged(this, changedProps); + } + + protected render(): TemplateResult { + if (!this.hass || !this._config) { + return html``; + } + + const stateObj = this.hass.states[this._config.entity] as + | SelectEntity + | undefined; + + if (!stateObj) { + return html` + + ${createEntityNotFoundWarning(this.hass, this._config.entity)} + + `; + } + + const pointer = + (this._config.tap_action && this._config.tap_action.action !== "none") || + (this._config.entity && + !DOMAINS_HIDE_MORE_INFO.includes(computeDomain(this._config.entity))); + + return html` + + + + ${stateObj.attributes.options + ? stateObj.attributes.options.map( + (option) => + html` + ${(stateObj.attributes.device_class && + this.hass!.localize( + `component.select.state.${stateObj.attributes.device_class}.${option}` + )) || + this.hass!.localize( + `component.select.state._.${option}` + ) || + option} + ` + ) + : ""} + + + `; + } + + protected updated(changedProps: PropertyValues) { + super.updated(changedProps); + + if (!this.hass || !this._config) { + return; + } + + const stateObj = this.hass.states[this._config.entity] as + | SelectEntity + | undefined; + + if (!stateObj) { + return; + } + + // Update selected after rendering the items or else it won't work in Firefox + if (stateObj.attributes.options) { + this.shadowRoot!.querySelector( + "paper-listbox" + )!.selected = stateObj.attributes.options.indexOf(stateObj.state); + } + } + + private _handleAction(ev: ActionHandlerEvent) { + handleAction(this, this.hass!, this._config!, ev.detail.action!); + } + + static get styles(): CSSResultGroup { + return css` + :host { + display: flex; + align-items: center; + } + ha-paper-dropdown-menu { + margin-left: 16px; + flex: 1; + } + paper-item { + cursor: pointer; + min-width: 200px; + } + .pointer { + cursor: pointer; + } + state-badge:focus { + outline: none; + background: var(--divider-color); + border-radius: 100%; + } + `; + } + + private _selectedChanged(ev): void { + const stateObj = this.hass!.states[this._config!.entity]; + const option = ev.target.selectedItem.option; + if (option === stateObj.state) { + return; + } + + forwardHaptic("light"); + + setSelectOption(this.hass!, stateObj.entity_id, option); + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-select-entity-row": HuiSelectEntityRow; + } +} diff --git a/src/state-summary/state-card-content.js b/src/state-summary/state-card-content.js index 8a365f612a..f3ee74bbf3 100644 --- a/src/state-summary/state-card-content.js +++ b/src/state-summary/state-card-content.js @@ -14,6 +14,7 @@ import "./state-card-media_player"; import "./state-card-number"; import "./state-card-scene"; import "./state-card-script"; +import "./state-card-select"; import "./state-card-timer"; import "./state-card-toggle"; import "./state-card-vacuum"; diff --git a/src/state-summary/state-card-select.ts b/src/state-summary/state-card-select.ts new file mode 100644 index 0000000000..0876f71e41 --- /dev/null +++ b/src/state-summary/state-card-select.ts @@ -0,0 +1,99 @@ +import "@polymer/paper-dropdown-menu/paper-dropdown-menu-light"; +import "@polymer/paper-item/paper-item"; +import "@polymer/paper-listbox/paper-listbox"; +import { + css, + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, +} from "lit"; +import { customElement, property } from "lit/decorators"; +import { stopPropagation } from "../common/dom/stop_propagation"; +import { computeStateName } from "../common/entity/compute_state_name"; +import "../components/entity/state-badge"; +import { SelectEntity, setSelectOption } from "../data/select"; +import type { HomeAssistant } from "../types"; + +@customElement("state-card-select") +class StateCardSelect extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public stateObj!: SelectEntity; + + protected render(): TemplateResult { + return html` + + + + ${this.stateObj.attributes.options.map( + (option) => + html` + ${(this.stateObj.attributes.device_class && + this.hass.localize( + `component.select.state.${this.stateObj.attributes.device_class}.${option}` + )) || + this.hass.localize(`component.select.state._.${option}`) || + option} + ` + )} + + + `; + } + + protected updated(changedProps: PropertyValues) { + super.updated(changedProps); + if (!changedProps.has("stateObj")) { + return; + } + // Update selected after rendering the items or else it won't work in Firefox + this.shadowRoot!.querySelector( + "paper-listbox" + )!.selected = this.stateObj.attributes.options.indexOf(this.stateObj.state); + } + + private _selectedOptionChanged(ev) { + const option = ev.target.selectedItem.option; + if (option === this.stateObj.state) { + return; + } + setSelectOption(this.hass, this.stateObj.entity_id, option); + } + + static get styles(): CSSResultGroup { + return css` + :host { + display: block; + } + + state-badge { + float: left; + margin-top: 10px; + } + + paper-dropdown-menu-light { + display: block; + margin-left: 53px; + } + + paper-item { + cursor: pointer; + min-width: 200px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "state-card-select": StateCardSelect; + } +}