From 7d30babe8b2308b746b4637f32d7824bb9563b6b Mon Sep 17 00:00:00 2001 From: Bruno Sabot Date: Fri, 9 Feb 2024 22:00:27 +0100 Subject: [PATCH 1/2] Add a state card --- README.md | 34 +++++- src/bubble-card.ts | 6 ++ src/cards/state.ts | 172 +++++++++++++++++++++++++++++++ src/editor/bubble-card-editor.ts | 36 ++++++- src/tools/tap-actions.ts | 4 +- 5 files changed, 247 insertions(+), 5 deletions(-) create mode 100644 src/cards/state.ts diff --git a/README.md b/README.md index 737928c8..3eb9e61f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Bubble Card is a minimalist and customizable card collection for Home Assistant ## Table of contents -**[`Installation`](#installation)** | **[`Configuration`](#configuration)** | **[`Pop-up`](#pop-up)** | **[`Horizontal buttons stack`](#horizontal-buttons-stack)** | **[`Button`](#button)** | **[`Custom button`](#custom-button)** | **[`Cover`](#cover)** | **[`Separator`](#separator)** | **[`Empty column`](#empty-column)** | **[`Actions`](#tap-double-tap-and-hold-actions)** | **[`Full example`](#full-example)** | **[`Styling`](#styling)** | **[`Conflicts`](#custom-components-conflicts)** | **[`Help`](#help)** | **[`Contribution`](#contribution)** +**[`Installation`](#installation)** | **[`Configuration`](#configuration)** | **[`Pop-up`](#pop-up)** | **[`Horizontal buttons stack`](#horizontal-buttons-stack)** | **[`Button`](#button)** | **[`Custom button`](#custom-button)** | **[`Cover`](#cover)** | **[`Separator`](#separator)** | **[`Empty column`](#empty-column)** | **[`State`](#state)** | **[`Actions`](#tap-double-tap-and-hold-actions)** | **[`Full example`](#full-example)** | **[`Styling`](#styling)** | **[`Conflicts`](#custom-components-conflicts)** | **[`Help`](#help)** | **[`Contribution`](#contribution)** ## Screenshots and features @@ -101,7 +101,7 @@ Most options can be configured in the GUI editor, except for custom styles, cust | Name | Type | Requirement | Supported options | Description | | --- | --- | --- | --- | --- | | `type` | string | **Required** | `custom:bubble-card` | Type of the card | -| `card_type` | string | **Required** | `button`, `cover`, `empty-column`, `horizontal-buttons-stack`, `pop-up` or `separator` | Type of the Bubble Card, see below | +| `card_type` | string | **Required** | `button`, `cover`, `empty-column`, `horizontal-buttons-stack`, `pop-up`, `separator` or `state` | Type of the Bubble Card, see below | | `styles` | object list | Optional | Any CSS stylesheets | Allows you to customize your cards, see [styling](#styling) | | `column_fix` | boolean or string | Optional | `true`, `false` (default) or a negative value like `-10` | Fix some issues with the dashboard layout, such as empty columns or misaligned cards. Add it in YAML to the **first** Bubble Card on your dashboard. You can also try to add a negative value to find the one that fit your dashboard. Then refresh the page. | @@ -371,6 +371,36 @@ type: custom:bubble-card card_type: empty-column ``` +## State + +This is a simple state display that could help you display any kind of data that don't have a specific action linked. + +This is only available in YAML for now. + +### Options + +| Name | Type | Requirement | Supported options | Description | +| --- | --- | --- | --- | --- | +| `entity` | string | **Required** (and soon optional) | Any entity | An entity for the state of the button | +| `name` | string | Optional | Any string | A name for your button, if not defined it will display the entity name | +| `icon` | string | Optional | Any `mdi:` icon or a link to a square image | An icon for your button, if not defined it will display the entity icon or the `entity-picture` | +| `show_state` | boolean | Optional | `true` (default) or `false` | Show the state of your `entity` below its `name` | +| `tap_action` | object | Optional | See [actions](#tap-double-tap-and-hold-actions) | Define the type of action on button click, if undefined, `more-info` will be used. | +| `double_tap_action` | object | Optional | See [actions](#tap-double-tap-and-hold-actions) | Define the type of action on the button double click, if undefined, `more-info` will be used. | +| `hold_action` | object | Optional | See [actions](#tap-double-tap-and-hold-actions) | Define the type of action on button hold, if undefined, `more-info` will be used. | + +### Example + +Here is an example of a button that toggles all the lights of a room and if you double tap or hold it, it will open a pop-up with all your other lights: + +```yaml +type: custom:bubble-card +card_type: state +entity: sensor.kitchen_temperature +name: Kitchen +icon: mdi:thermometer +``` + ## Tap, double tap and hold actions You can also use HA default tap actions, double tap actions and hold actions on the icons of the buttons, the pop-ups and the covers. This allows you to display the “more info” window by holding the icon and to turn on/off the lamp of a slider by a single tap for example. diff --git a/src/bubble-card.ts b/src/bubble-card.ts index 8820834f..0dd7734e 100644 --- a/src/bubble-card.ts +++ b/src/bubble-card.ts @@ -7,6 +7,7 @@ import { handleButton } from './cards/button.ts'; import { handleSeparator } from './cards/separator.ts'; import { handleCover } from './cards/cover.ts'; import { handleEmptyColumn } from './cards/empty-column.ts'; +import { handleState } from './cards/state.ts'; import BubbleCardEditor from './editor/bubble-card-editor.ts'; let editor; @@ -54,6 +55,11 @@ class BubbleCard extends HTMLElement { case 'horizontal-buttons-stack' : handleHorizontalButtonsStack(this); break; + + // Intitalize state card + case 'state': + handleState(this); + break; } checkResources(hass); diff --git a/src/cards/state.ts b/src/cards/state.ts new file mode 100644 index 00000000..dd5087c6 --- /dev/null +++ b/src/cards/state.ts @@ -0,0 +1,172 @@ +import { addStyles, createIcon } from '../tools/style.ts'; +import { forwardHaptic } from '../tools/utils.ts'; +import { addActions } from '../tools/tap-actions.ts'; +import { getVariables } from '../var/cards.ts'; + +export function handleState(context) { + const hass = context._hass; + const editor = context.editor; + + let { + customStyles, + entityId, + icon, + name, + state, + stateChanged, + formatedState, + } = getVariables(context, context.config, hass, editor); + + formatedState = entityId && (stateChanged || editor) ? hass.formatEntityState(hass.states[entityId]) : ''; + let showState = context.config.show_state === false ? false : context.config.show_state; + + if (!context.buttonAdded) { + const buttonContainer = document.createElement("div"); + buttonContainer.setAttribute("class", "button-container"); + context.content.appendChild(buttonContainer); + } + + const iconContainer = document.createElement('div'); + iconContainer.setAttribute('class', 'icon-container'); + context.iconContainer = iconContainer; + + const nameContainer = document.createElement('div'); + nameContainer.setAttribute('class', 'name-container'); + + const stateCard = document.createElement('div'); + stateCard.setAttribute('class', 'state-card'); + + if (!context.buttonContainer || editor) { + // Fix for editor mode + if (editor && context.buttonContainer) { + while (context.buttonContainer.firstChild) { + context.buttonContainer.removeChild(context.buttonContainer.firstChild); + } + context.eventAdded = false; + context.wasEditing = true; + } + // End of fix + + context.buttonContainer = context.content.querySelector(".button-container"); + + context.buttonContainer.appendChild(stateCard); + stateCard.appendChild(iconContainer); + stateCard.appendChild(nameContainer); + context.stateCard = context.content.querySelector(".state-card"); + + createIcon(context, entityId, icon, iconContainer, editor); + nameContainer.innerHTML = ` +

${name}

+ ${!showState ? '' : `

${formatedState}

`} + `; + + context.buttonAdded = true; + } + + if (showState && formatedState) { + context.content.querySelector(".state").textContent = formatedState; + } + + function tapFeedback(content) { + forwardHaptic("success"); + let feedbackElement = content.querySelector('.feedback-element'); + if (!feedbackElement) { + feedbackElement = document.createElement('div'); + feedbackElement.setAttribute('class', 'feedback-element'); + content.appendChild(feedbackElement); + } + + feedbackElement.style.animation = 'tap-feedback .5s'; + setTimeout(() => { + feedbackElement.style.animation = 'none'; + content.removeChild(feedbackElement); + }, 500); + } + + if (!context.eventAdded) { + stateCard.addEventListener('click', () => tapFeedback(context.stateCard), { passive: true }); + addActions(stateCard, context.config, hass, forwardHaptic); + context.eventAdded = true; + } + + const buttonStyles = ` + ha-card { + margin-top: 0 !important; + background: none !important; + opacity: ${state !== 'unavailable' ? '1' : '0.5'}; + } + + .button-container { + width: 100%; + height: 50px; + z-index: 0; + background-color: var(--background-color-2,var(--secondary-background-color)); + border-radius: 25px; + mask-image: radial-gradient(white, black); + -webkit-transform: translateZ(0); + overflow: hidden; + } + + .state-card { + display: inline-flex; + position: absolute; + height: 100%; + width: 100%; + transition: background-color 1.5s; + background-color: rgba(0,0,0,0); + cursor: pointer !important; + } + + .icon-container { + display: flex; + flex-wrap: wrap; + align-content: center; + justify-content: center; + z-index: 1; + min-width: 38px; + min-height: 38px; + margin: 6px; + border-radius: 50%; + background-color: var(--card-background-color,var(--ha-card-background)); + } + + .state-card ha-icon { + display: flex; + width: 22px; + } + + .name-container { + position: relative; + display: ${!showState ? 'inline-flex' : 'block'}; + margin-left: 4px; + z-index: 1; + font-weight: 600; + align-items: center; + line-height: ${!showState ? '16px' : '4px'}; + padding-right: 16px; + } + + .state { + font-size: 12px; + opacity: 0.7; + } + + .feedback-element { + position: absolute; + top: 0; + left: 0; + opacity: 0; + width: 100%; + height: 100%; + background-color: rgb(0,0,0); + } + + @keyframes tap-feedback { + 0% {transform: translateX(-100%); opacity: 0;} + 64% {transform: translateX(0); opacity: 0.1;} + 100% {transform: translateX(100%); opacity: 0;} + } +`; + + addStyles(hass, context, buttonStyles, customStyles, state, entityId, stateChanged); +} \ No newline at end of file diff --git a/src/editor/bubble-card-editor.ts b/src/editor/bubble-card-editor.ts index cd83659c..3d81f14e 100644 --- a/src/editor/bubble-card-editor.ts +++ b/src/editor/bubble-card-editor.ts @@ -145,7 +145,8 @@ export default class BubbleCardEditor extends LitElement { } get _show_state() { - return this._config.show_state || false; + const defaultState = this._config.card_type === 'state' ? true : false; + return this._config.show_state || defaultState; } get _hide_backdrop() { @@ -208,6 +209,10 @@ export default class BubbleCardEditor extends LitElement { { 'label': 'Separator', 'value': 'separator' + }, + { + 'label': 'State', + 'value': 'state' } ]; @@ -630,6 +635,35 @@ export default class BubbleCardEditor extends LitElement { ${this.makeVersion()} `; + } else if (this._config.card_type === 'state') { + return html` +
+ ${this.makeDropdown("Card type", "card_type", cardTypeList)} +

State

+ A card to display an entity state + ${this.makeDropdown(this._button_type !== 'slider' ? "Entity (toggle)" : "Entity (light or media_player)", "entity", allEntitiesList)} + + +
+ +
+
+ + ${this.makeDropdown("Optional - Icon", "icon")} + ${this.makeVersion()} +
+ `; } else if (!this._config.card_type) { return html`
diff --git a/src/tools/tap-actions.ts b/src/tools/tap-actions.ts index 7da5363f..360dc526 100644 --- a/src/tools/tap-actions.ts +++ b/src/tools/tap-actions.ts @@ -65,10 +65,10 @@ export function sendActionEvent(element, config, action) { action: "more-info" }, double_tap_action: config.double_tap_action || { - action: "toggle" + action: config.card_type === "state" ? "more-info" : "toggle" }, hold_action: config.hold_action || { - action: "toggle" + action: config.card_type === "state" ? "more-info" : "toggle" } }; From a3e5ac7f89a305a8689cadd5ccdd5a1c9f4a168d Mon Sep 17 00:00:00 2001 From: Bruno Sabot Date: Sat, 17 Feb 2024 11:37:01 +0100 Subject: [PATCH 2/2] chore: improve state card performance --- src/cards/state.ts | 337 +++++++++++++++++++++++---------------------- src/tools/utils.ts | 49 +++++++ 2 files changed, 220 insertions(+), 166 deletions(-) diff --git a/src/cards/state.ts b/src/cards/state.ts index dd5087c6..70f49e1c 100644 --- a/src/cards/state.ts +++ b/src/cards/state.ts @@ -1,172 +1,177 @@ -import { addStyles, createIcon } from '../tools/style.ts'; -import { forwardHaptic } from '../tools/utils.ts'; +import { createElement, forwardHaptic, getIcon, getImage, getName, getState, tapFeedback } from '../tools/utils.ts'; import { addActions } from '../tools/tap-actions.ts'; -import { getVariables } from '../var/cards.ts'; -export function handleState(context) { - const hass = context._hass; - const editor = context.editor; - - let { - customStyles, - entityId, - icon, - name, - state, - stateChanged, - formatedState, - } = getVariables(context, context.config, hass, editor); - - formatedState = entityId && (stateChanged || editor) ? hass.formatEntityState(hass.states[entityId]) : ''; - let showState = context.config.show_state === false ? false : context.config.show_state; - - if (!context.buttonAdded) { - const buttonContainer = document.createElement("div"); - buttonContainer.setAttribute("class", "button-container"); - context.content.appendChild(buttonContainer); - } - - const iconContainer = document.createElement('div'); - iconContainer.setAttribute('class', 'icon-container'); - context.iconContainer = iconContainer; - - const nameContainer = document.createElement('div'); - nameContainer.setAttribute('class', 'name-container'); - - const stateCard = document.createElement('div'); - stateCard.setAttribute('class', 'state-card'); - - if (!context.buttonContainer || editor) { - // Fix for editor mode - if (editor && context.buttonContainer) { - while (context.buttonContainer.firstChild) { - context.buttonContainer.removeChild(context.buttonContainer.firstChild); - } - context.eventAdded = false; - context.wasEditing = true; - } - // End of fix - - context.buttonContainer = context.content.querySelector(".button-container"); - - context.buttonContainer.appendChild(stateCard); - stateCard.appendChild(iconContainer); - stateCard.appendChild(nameContainer); - context.stateCard = context.content.querySelector(".state-card"); - - createIcon(context, entityId, icon, iconContainer, editor); - nameContainer.innerHTML = ` -

${name}

- ${!showState ? '' : `

${formatedState}

`} - `; - - context.buttonAdded = true; - } - - if (showState && formatedState) { - context.content.querySelector(".state").textContent = formatedState; - } - - function tapFeedback(content) { - forwardHaptic("success"); - let feedbackElement = content.querySelector('.feedback-element'); - if (!feedbackElement) { - feedbackElement = document.createElement('div'); - feedbackElement.setAttribute('class', 'feedback-element'); - content.appendChild(feedbackElement); - } - - feedbackElement.style.animation = 'tap-feedback .5s'; - setTimeout(() => { - feedbackElement.style.animation = 'none'; - content.removeChild(feedbackElement); - }, 500); - } - - if (!context.eventAdded) { - stateCard.addEventListener('click', () => tapFeedback(context.stateCard), { passive: true }); - addActions(stateCard, context.config, hass, forwardHaptic); - context.eventAdded = true; - } - - const buttonStyles = ` - ha-card { - margin-top: 0 !important; - background: none !important; - opacity: ${state !== 'unavailable' ? '1' : '0.5'}; - } - - .button-container { - width: 100%; - height: 50px; - z-index: 0; - background-color: var(--background-color-2,var(--secondary-background-color)); - border-radius: 25px; - mask-image: radial-gradient(white, black); - -webkit-transform: translateZ(0); - overflow: hidden; - } - - .state-card { - display: inline-flex; - position: absolute; - height: 100%; - width: 100%; - transition: background-color 1.5s; - background-color: rgba(0,0,0,0); - cursor: pointer !important; - } +function createStructure(context) { + context.elements = {}; + context.elements.stateCardContainer = createElement('div', 'state-card-container'); + context.elements.stateCard = createElement('div', 'state-card'); + context.elements.nameContainer = createElement('div', 'name-container'); + context.elements.iconContainer = createElement('div', 'icon-container'); + context.elements.name = createElement('p', 'name'); + context.elements.state = createElement('p', 'state'); + context.elements.feedback = createElement('div', 'feedback-element'); + context.elements.icon = createElement('ha-icon', 'icon'); + context.elements.image = createElement('div', 'entity-picture'); + context.elements.style = createElement('style'); + + context.elements.feedback.style.display = 'none'; + context.elements.style.innerText = stateCardStyles; + context.elements.stateCard.addEventListener('click', () => tapFeedback(context), { passive: true }); + + addActions(context.elements.stateCard, context.config, context._hass, forwardHaptic); + + context.elements.iconContainer.appendChild(context.elements.icon); + context.elements.iconContainer.appendChild(context.elements.image); + + context.elements.nameContainer.appendChild(context.elements.name); + context.elements.nameContainer.appendChild(context.elements.state); + context.elements.stateCard.appendChild(context.elements.iconContainer); + context.elements.stateCard.appendChild(context.elements.nameContainer); + context.elements.stateCard.appendChild(context.elements.feedback); + + if (!context.editor) { + context.elements.stateCardContainer.appendChild(context.elements.stateCard); + } + + context.content.innerHTML = ''; + context.content.appendChild(context.elements.stateCardContainer); + context.content.appendChild(context.elements.style); + + context.cardType = 'state'; +} + +function updateValues(context) { + const name = getName(context); + const state = getState(context); + const showState = context.config.show_state !== false; + const formattedState = context._hass.formatEntityState(context._hass.states[context.config.entity]); + + if (state === 'unavailable') { + context.card.style.opacity = '0.5'; + } else { + context.card.style.opacity = '1'; + } + + if (name !== context.elements.name.innerText) { + context.elements.name.innerText = name; + } + + if (showState && formattedState !== context.elements.state.innerText) { + context.elements.state.style.display = ''; + context.elements.state.innerText = formattedState; + context.elements.nameContainer.style.display = 'block'; + context.elements.nameContainer.style.lineHeight = '4px'; + } + if (showState === false) { + context.elements.state.style.display = 'none'; + context.elements.nameContainer.style.display = 'inline-flex'; + context.elements.nameContainer.style.lineHeight = '16px'; + } + + const icon = getIcon(context); + const image = getImage(context); + if (icon !== '') { + context.elements.icon.icon = icon; + context.elements.icon.style.display = ''; + context.elements.image.style.display = 'none'; + } else if (image !== '') { + context.elements.image.style.backgroundImage = 'url(' + image + ')'; + context.elements.icon.style.display = 'none'; + context.elements.image.style.display = ''; + } else { + context.elements.icon.style.display = 'none'; + context.elements.image.style.display = 'none'; + } +} - .icon-container { - display: flex; - flex-wrap: wrap; - align-content: center; - justify-content: center; - z-index: 1; - min-width: 38px; - min-height: 38px; - margin: 6px; - border-radius: 50%; - background-color: var(--card-background-color,var(--ha-card-background)); - } - - .state-card ha-icon { - display: flex; - width: 22px; +export function handleState(context) { + if (context.cardType !== 'state') { + createStructure(context) } - .name-container { - position: relative; - display: ${!showState ? 'inline-flex' : 'block'}; - margin-left: 4px; - z-index: 1; - font-weight: 600; - align-items: center; - line-height: ${!showState ? '16px' : '4px'}; - padding-right: 16px; - } - - .state { - font-size: 12px; - opacity: 0.7; - } - - .feedback-element { - position: absolute; - top: 0; - left: 0; - opacity: 0; - width: 100%; - height: 100%; - background-color: rgb(0,0,0); - } - - @keyframes tap-feedback { - 0% {transform: translateX(-100%); opacity: 0;} - 64% {transform: translateX(0); opacity: 0.1;} - 100% {transform: translateX(100%); opacity: 0;} - } + updateValues(context); +} + +const stateCardStyles = ` + ha-card { + margin-top: 0 !important; + background: none !important; + } + + .state-card-container { + width: 100%; + height: 50px; + z-index: 0; + background-color: var(--background-color-2, var(--secondary-background-color)); + border-radius: 25px; + mask-image: radial-gradient(white, black); + -webkit-transform: translateZ(0); + overflow: hidden; + } + + .state-card { + display: inline-flex; + position: absolute; + height: 100%; + width: 100%; + transition: background-color 1.5s; + background-color: rgba(0, 0, 0, 0); + cursor: pointer !important; + } + + .state-card .icon-container { + display: flex; + flex-wrap: wrap; + align-content: center; + justify-content: center; + z-index: 1; + min-width: 38px; + min-height: 38px; + margin: 6px; + border-radius: 50%; + background-color: var(--card-background-color, var(--ha-card-background)); + overflow: hidden; + } + + .state-card ha-icon { + display: flex; + width: 22px; + } + + .state-card .entity-picture { + background-size: cover; + background-position: center; + height: 100%; + width: 100%; + } + + .state-card .name-container { + position: relative; + margin-left: 4px; + z-index: 1; + font-weight: 600; + align-items: center; + padding-right: 16px; + } + + .state-card .state { + font-size: 12px; + opacity: 0.7; + } + + .state-card .feedback-element { + position: absolute; + top: 0; + left: 0; + opacity: 0; + width: 100%; + height: 100%; + background-color: rgb(0, 0, 0); + } + + @keyframes tap-feedback { + 0% { transform: translateX(-100%); opacity: 0; } + 64% { transform: translateX(0); opacity: 0.1; } + 100% { transform: translateX(100%); opacity: 0; } + } `; - - addStyles(hass, context, buttonStyles, customStyles, state, entityId, stateChanged); -} \ No newline at end of file diff --git a/src/tools/utils.ts b/src/tools/utils.ts index 33efa16c..68c99b45 100644 --- a/src/tools/utils.ts +++ b/src/tools/utils.ts @@ -47,9 +47,58 @@ export function toggleEntity(hass, entityId) { }); } +export function tapFeedback(context) { + if (context.elements.feedback === undefined) return; + forwardHaptic("success"); + context.elements.feedback.style.display = ''; + context.elements.feedback.style.animation = 'tap-feedback .5s'; + setTimeout(() => { + context.elements.feedback.style.animation = 'none'; + context.elements.feedback.style.display = 'none'; + }, 500); +} + +export function getIcon(context) { + const entityIcon = context._hass.states[context.config.entity]?.attributes.icon; + const configIcon = context.config.icon; + + if (configIcon) return configIcon; + if (entityIcon) return entityIcon; + + return ''; +} + +export function getImage(context) { + const entityImage = context._hass.states[context.config.entity]?.attributes.entity_picture; + + if (entityImage) return entityImage; + + return ''; +} + +export function getName(context) { + const configName = context.config.name; + const entityName = context._hass.states[context.config.entity]?.attributes.friendly_name + + if (configName) return configName; + if (entityName) return entityName; + + return ''; +} + +export function getState(context) { + return context._hass.states[context.config.entity]?.state ?? ''; +} +export function createElement(tag, className = '') { + const element = document.createElement(tag); + if (className !== '') { + element.classList.add(className); + } + return element; +} \ No newline at end of file