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..70f49e1c --- /dev/null +++ b/src/cards/state.ts @@ -0,0 +1,177 @@ +import { createElement, forwardHaptic, getIcon, getImage, getName, getState, tapFeedback } from '../tools/utils.ts'; +import { addActions } from '../tools/tap-actions.ts'; + +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'; + } +} + +export function handleState(context) { + if (context.cardType !== 'state') { + createStructure(context) + } + + 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; } + } +`; 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" } }; 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