Add a nice locking mechanism (#139)

* Add a nice locking mecanism

* Update documentation

* Fix typo

* Remove useless styles

* Fix for touch devices

* Disable ripple effect while locked

* fix blank card height

* Show last changed instead of label

* Support for light temperature with color auto

* Style for last_changed

* Update documentation
This commit is contained in:
Jérôme W 2019-05-04 13:20:33 +02:00 committed by GitHub
parent 364580a281
commit 880e7e37e1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 298 additions and 19 deletions

View File

@ -59,13 +59,15 @@ Lovelace Button card for your entities.
| `show_state` | boolean | `false` | `true` \| `false` | Show the state on the card. defaults to false if not set |
| `show_icon` | boolean | `true` | `true` \| `false` | Wether to show the icon or not. Unless redefined in `icon`, uses the default entity icon from hass |
| `show_units` | boolean | `true` | `true` \| `false` | Display or hide the units of a sensor, if any. |
| `show_label` | boolean | `false` | `true` \| `false` | Display or hide the `label`/`label_template`
| `show_label` | boolean | `false` | `true` \| `false` | Display or hide the `label`/`label_template` |
| `show_last_changed` | boolean | `false` | `true` \| `false` | Replace the label altogether and display the the `last_changed` attribute in a nice way (eg: `12 minutes ago`) |
| `show_entity_picture` | boolean | `false` | `true` \| `false` | Replace the icon by the entity picture (if any) or the custom picture (if any). Falls back to using the icon if both are undefined |
| `entity_picture` | string | optional | Can be any of `/local/*` file or a URL | Will override the icon/the default entity_picture with your own image. Best is to use a square image. You can also define one per state |
| `units` | string | optional | `Kb/s`, `lux`, ... | Override or define the units to display after the state of the entity. If omitted, it's using the entity's units |
| `styles` | object list | optional | | See [styles](#styles) |
| `state` | object list | optional | See [State](#State) | State to use for the color, icon and style of the button. Multiple states can be defined |
| `confirmation` | string | optional | Free-form text | Show a confirmation popup on tap with defined text |
| `lock` | boolean | `false` | `true` \| `false` | See [lock](#lock). This will display a normal button with a lock symbol in the corner. Clicking the button will make the lock go away and enable the button to be manoeuvred for five seconds |
| `layout` | string | optional | See [Layout](#Layout) | The layout of the button can be modified using this option |
### Action
@ -131,7 +133,18 @@ Multiple values are possible, see the image below for examples:
### Templates
`label_template` supports templating as well as `value` for `state` when `operator: template`
* `label_template`: It will be interpreted as javascript code and the code should return a string
* `label_template`: It will be interpreted as javascript code and the code should return a string.
`label_template` supports inline HTML, so you can do stuff like:
```yaml
label_template: >
return 'Connection: '
+ (states['switch.connection'].state === 'on'
? '<span style="color: #00FF00;">enabled</span>'
: '<span style="color: #FF0000;">disabled</span>')
+ ' / '
+ (states['binary_sensor.status'].state === 'on' ? 'connected' : 'disconnected')
```
![label-template-example](examples/label_template.png)
* `value` for `state` when `operator: template`: It will be interpreted as javascript code and the code should return a boolean (`true` or `false`)
Inside the javascript code, you'll have access to those variables:
@ -699,6 +712,23 @@ Example with `template`:
- color: green
```
### Lock
![lock-animation](examples/lock.gif)
```yaml
- type: horizontal-stack
cards:
- type: "custom:button-card"
entity: switch.test
lock: true
- type: "custom:button-card"
color_type: card
lock: true
color: black
entity: switch.test
```
## Credits
- [ciotlosm](https://github.com/ciotlosm) for the readme template and the awesome examples

153
dist/button-card.js vendored
View File

@ -2353,6 +2353,47 @@ const styleMap = directive(styleInfo => part => {
styleMapCache.set(part, styleInfo);
});
/**
* @license
* Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
* This code may only be used under the BSD style license found at
* http://polymer.github.io/LICENSE.txt
* The complete set of authors may be found at
* http://polymer.github.io/AUTHORS.txt
* The complete set of contributors may be found at
* http://polymer.github.io/CONTRIBUTORS.txt
* Code distributed by Google as part of the polymer project is also
* subject to an additional IP rights grant found at
* http://polymer.github.io/PATENTS.txt
*/
// For each part, remember the value that was last rendered to the part by the
// unsafeHTML directive, and the DocumentFragment that was last set as a value.
// The DocumentFragment is used as a unique key to check if the last value
// rendered to the part was with unsafeHTML. If not, we'll always re-render the
// value passed to unsafeHTML.
const previousValues = new WeakMap();
/**
* Renders the result as HTML, rather than text.
*
* Note, this is unsafe to use with any user-provided input that hasn't been
* sanitized or escaped, as it may lead to cross-site-scripting
* vulnerabilities.
*/
const unsafeHTML = directive(value => part => {
if (!(part instanceof NodePart)) {
throw new Error('unsafeHTML can only be used in text bindings');
}
const previousValue = previousValues.get(part);
if (previousValue !== undefined && isPrimitive(value) && value === previousValue.value && part.value === previousValue.fragment) {
return;
}
const template = document.createElement('template');
template.innerHTML = value; // innerHTML casts to string internally
const fragment = document.importNode(template.content, true);
part.setValue(fragment);
previousValues.set(part, { value, fragment });
});
/** Constants to be used in the frontend. */
// Constants should be alphabetically sorted by name.
// Arrays with values should be alphabetically sorted if order doesn't matter.
@ -3290,6 +3331,15 @@ var TinyColor = function () {
};
return TinyColor;
}();
function tinycolor(color, opts) {
if (color === void 0) {
color = '';
}
if (opts === void 0) {
opts = {};
}
return new TinyColor(color, opts);
}
function computeDomain(entityId) {
return entityId.substr(0, entityId.indexOf('.'));
@ -3311,6 +3361,17 @@ function getFontColorBasedOnBackgroundColor(backgroundColor) {
return 'rgb(234, 234, 234)'; // dark colors - white font
}
}
function getLightColorBasedOnTemperature(current, min, max) {
const high = new TinyColor('rgb(255, 160, 0)'); // orange-ish
const low = new TinyColor('rgb(166, 209, 255)'); // blue-ish
const middle = new TinyColor('white');
const mixAmount = (current - min) / (max - min) * 100;
if (mixAmount < 50) {
return tinycolor(low).mix(middle, mixAmount * 2).toRgbString();
} else {
return tinycolor(middle).mix(high, (mixAmount - 50) * 2).toRgbString();
}
}
function buildNameStateConcat(name, stateString) {
if (!name && !stateString) {
return undefined;
@ -3637,6 +3698,7 @@ const styles = css`
cursor: pointer;
overflow: hidden;
box-sizing: border-box;
position: relative;
}
ha-card.disabled {
pointer-events: none;
@ -3661,6 +3723,34 @@ const styles = css`
white-space: nowrap;
overflow: hidden;
}
#overlay {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
text-align: right;
z-index: 1;
}
#lock {
margin-top: 8px;
opacity: 0.5;
margin-right: 7px;
-webkit-animation-duration: 5s;
animation-duration: 5s;
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
}
@keyframes fadeOut{
0% {opacity: 0.5;}
20% {opacity: 0;}
80% {opacity: 0;}
100% {opacity: 0.5;}
}
.fadeOut {
-webkit-animation-name: fadeOut;
animation-name: fadeOut;
}
@keyframes blink{
0%{opacity:0;}
50%{opacity:1;}
@ -4031,6 +4121,11 @@ let ButtonCard = class ButtonCard extends LitElement {
if (state.attributes.brightness) {
color = applyBrightnessToColor(color, (state.attributes.brightness + 245) / 5);
}
} else if (state.attributes.color_temp && state.attributes.min_mireds && state.attributes.max_mireds) {
color = getLightColorBasedOnTemperature(state.attributes.color_temp, state.attributes.min_mireds, state.attributes.max_mireds);
if (state.attributes.brightness) {
color = applyBrightnessToColor(color, (state.attributes.brightness + 245) / 5);
}
} else if (state.attributes.brightness) {
color = applyBrightnessToColor(this._getDefaultColorForState(state), (state.attributes.brightness + 245) / 5);
} else {
@ -4127,6 +4222,9 @@ let ButtonCard = class ButtonCard extends LitElement {
}
return units;
}
_buildLastChanged(state, style) {
return state ? html`<ha-relative-time .hass="${this.hass}" .datetime="${state.last_changed}" class="label" style=${styleMap(style)}></ha-relative-time>` : html``;
}
_buildLabel(state, configState) {
if (!this.config.show_label) {
return undefined;
@ -4176,12 +4274,11 @@ let ButtonCard = class ButtonCard extends LitElement {
_rotate(configState) {
return configState && configState.spin ? true : false;
}
_blankCardColoredHtml(state, cardStyle) {
const color = this._buildCssColorAttribute(state, undefined);
const fontColor = getFontColorBasedOnBackgroundColor(color);
_blankCardColoredHtml(cardStyle) {
const blankCardStyle = Object.assign({ background: 'none', 'box-shadow': 'none' }, cardStyle);
return html`
<ha-card class="disabled" style=${styleMap(cardStyle)}>
<div style="color: ${fontColor}; background-color: ${color};"></div>
<ha-card class="disabled" style=${styleMap(blankCardStyle)}>
<div></div>
</ha-card>
`;
}
@ -4191,6 +4288,7 @@ let ButtonCard = class ButtonCard extends LitElement {
const color = this._buildCssColorAttribute(state, configState);
let buttonColor = color;
let cardStyle = {};
const lockStyle = {};
const configCardStyle = this._buildStyleGeneric(configState, 'card');
if (configCardStyle.width) {
this.style.setProperty('flex', '0 0 auto');
@ -4198,12 +4296,13 @@ let ButtonCard = class ButtonCard extends LitElement {
}
switch (this.config.color_type) {
case 'blank-card':
return this._blankCardColoredHtml(state, configCardStyle);
return this._blankCardColoredHtml(configCardStyle);
case 'card':
case 'label-card':
{
const fontColor = getFontColorBasedOnBackgroundColor(color);
cardStyle.color = fontColor;
lockStyle.color = fontColor;
cardStyle['background-color'] = color;
cardStyle = Object.assign({}, cardStyle, configCardStyle);
buttonColor = 'inherit';
@ -4215,11 +4314,22 @@ let ButtonCard = class ButtonCard extends LitElement {
}
return html`
<ha-card class="button-card-main ${this._isClickable(state) ? '' : 'disabled'}" style=${styleMap(cardStyle)} @ha-click="${this._handleTap}" @ha-hold="${this._handleHold}" .longpress="${longPress()}" .config="${this.config}">
${this._getLock(lockStyle)}
${this._buttonContent(state, configState, buttonColor)}
<mwc-ripple></mwc-ripple>
${this.config.lock ? '' : html`<paper-ripple id="ripple"></paper-ripple>`}
</ha-card>
`;
}
_getLock(lockStyle) {
if (this.config.lock) {
return html`
<div id="overlay" style=${styleMap(lockStyle)} @click=${this._handleLock} @touchstart=${this._handleLock}>
<ha-icon id="lock" icon="mdi:lock-outline"></iron-icon>
</div>
`;
}
return html``;
}
_buttonContent(state, configState, color) {
const name = this._buildName(state, configState);
const stateString = this._buildStateString(state);
@ -4239,6 +4349,7 @@ let ButtonCard = class ButtonCard extends LitElement {
const nameStyleFromConfig = this._buildStyleGeneric(configState, 'name');
const stateStyleFromConfig = this._buildStyleGeneric(configState, 'state');
const labelStyleFromConfig = this._buildStyleGeneric(configState, 'label');
const lastChangedTemplate = this._buildLastChanged(state, labelStyleFromConfig);
if (!iconTemplate) itemClass.push('no-icon');
if (!name) itemClass.push('no-name');
if (!stateString) itemClass.push('no-state');
@ -4248,7 +4359,8 @@ let ButtonCard = class ButtonCard extends LitElement {
${iconTemplate ? iconTemplate : ''}
${name ? html`<div class="name" style=${styleMap(nameStyleFromConfig)}>${name}</div>` : ''}
${stateString ? html`<div class="state" style=${styleMap(stateStyleFromConfig)}>${stateString}</div>` : ''}
${label ? html`<div class="label" style=${styleMap(labelStyleFromConfig)}>${label}</div>` : ''}
${label && !this.config.show_last_changed ? html`<div class="label" style=${styleMap(labelStyleFromConfig)}>${unsafeHTML(label)}</div>` : ''}
${this.config.show_last_changed ? lastChangedTemplate : ''}
</div>
`;
}
@ -4330,6 +4442,31 @@ let ButtonCard = class ButtonCard extends LitElement {
const config = ev.target.config;
handleClick(this, this.hass, config, true);
}
_handleLock(ev) {
ev.stopPropagation();
const overlay = this.shadowRoot.getElementById('overlay');
const haCard = this.shadowRoot.firstElementChild;
overlay.style.setProperty('pointer-events', 'none');
const paperRipple = document.createElement('paper-ripple');
const lock = this.shadowRoot.getElementById('lock');
if (lock) {
haCard.appendChild(paperRipple);
const icon = document.createAttribute('icon');
icon.value = 'mdi:lock-open-outline';
lock.attributes.setNamedItem(icon);
lock.classList.add('fadeOut');
}
window.setTimeout(() => {
overlay.style.setProperty('pointer-events', '');
if (lock) {
lock.classList.remove('fadeOut');
const icon = document.createAttribute('icon');
icon.value = 'mdi:lock-outline';
lock.attributes.setNamedItem(icon);
haCard.removeChild(paperRipple);
}
}, 5000);
}
};
__decorate([property()], ButtonCard.prototype, "hass", void 0);
__decorate([property()], ButtonCard.prototype, "config", void 0);

BIN
examples/label_template.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
examples/lock.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 757 KiB

View File

@ -8,6 +8,7 @@ import {
PropertyValues,
} from 'lit-element';
import { styleMap, StyleInfo } from 'lit-html/directives/style-map';
import { unsafeHTML } from 'lit-html/directives/unsafe-html';
import {
HassEntity,
} from 'home-assistant-js-websocket';
@ -25,6 +26,7 @@ import {
buildNameStateConcat,
applyBrightnessToColor,
hasConfigOrEntityChanged,
getLightColorBasedOnTemperature,
} from './helpers';
import { handleClick } from './handle-click';
import { longPress } from './long-press';
@ -138,6 +140,17 @@ class ButtonCard extends LitElement {
if (state.attributes.brightness) {
color = applyBrightnessToColor(color, (state.attributes.brightness + 245) / 5);
}
} else if (state.attributes.color_temp
&& state.attributes.min_mireds
&& state.attributes.max_mireds) {
color = getLightColorBasedOnTemperature(
state.attributes.color_temp,
state.attributes.min_mireds,
state.attributes.max_mireds,
);
if (state.attributes.brightness) {
color = applyBrightnessToColor(color, (state.attributes.brightness + 245) / 5);
}
} else if (state.attributes.brightness) {
color = applyBrightnessToColor(
this._getDefaultColorForState(state), (state.attributes.brightness + 245) / 5,
@ -260,6 +273,13 @@ class ButtonCard extends LitElement {
return units;
}
private _buildLastChanged(
state: HassEntity | undefined,
style: StyleInfo,
): TemplateResult {
return state ? html`<ha-relative-time .hass="${this.hass}" .datetime="${state.last_changed}" class="label" style=${styleMap(style)}></ha-relative-time>` : html``;
}
private _buildLabel(
state: HassEntity | undefined,
configState: StateConfig | undefined,
@ -322,14 +342,16 @@ class ButtonCard extends LitElement {
}
private _blankCardColoredHtml(
state: HassEntity | undefined,
cardStyle: StyleInfo,
): TemplateResult {
const color = this._buildCssColorAttribute(state, undefined);
const fontColor = getFontColorBasedOnBackgroundColor(color);
const blankCardStyle = {
background: 'none',
'box-shadow': 'none',
...cardStyle,
};
return html`
<ha-card class="disabled" style=${styleMap(cardStyle)}>
<div style="color: ${fontColor}; background-color: ${color};"></div>
<ha-card class="disabled" style=${styleMap(blankCardStyle)}>
<div></div>
</ha-card>
`;
}
@ -340,6 +362,7 @@ class ButtonCard extends LitElement {
const color = this._buildCssColorAttribute(state, configState);
let buttonColor = color;
let cardStyle: StyleInfo = {};
const lockStyle: StyleInfo = {};
const configCardStyle = this._buildStyleGeneric(configState, 'card');
if (configCardStyle.width) {
@ -348,11 +371,12 @@ class ButtonCard extends LitElement {
}
switch (this.config!.color_type) {
case 'blank-card':
return this._blankCardColoredHtml(state, configCardStyle);
return this._blankCardColoredHtml(configCardStyle);
case 'card':
case 'label-card': {
const fontColor = getFontColorBasedOnBackgroundColor(color);
cardStyle.color = fontColor;
lockStyle.color = fontColor;
cardStyle['background-color'] = color;
cardStyle = { ...cardStyle, ...configCardStyle };
buttonColor = 'inherit';
@ -365,12 +389,24 @@ class ButtonCard extends LitElement {
return html`
<ha-card class="button-card-main ${this._isClickable(state) ? '' : 'disabled'}" style=${styleMap(cardStyle)} @ha-click="${this._handleTap}" @ha-hold="${this._handleHold}" .longpress="${longPress()}" .config="${this.config}">
${this._getLock(lockStyle)}
${this._buttonContent(state, configState, buttonColor)}
<mwc-ripple></mwc-ripple>
${this.config!.lock ? '' : html`<paper-ripple id="ripple"></paper-ripple>`}
</ha-card>
`;
}
private _getLock(lockStyle: StyleInfo): TemplateResult {
if (this.config!.lock) {
return html`
<div id="overlay" style=${styleMap(lockStyle)} @click=${this._handleLock} @touchstart=${this._handleLock}>
<ha-icon id="lock" icon="mdi:lock-outline"></iron-icon>
</div>
`;
}
return html``;
}
private _buttonContent(
state: HassEntity | undefined,
configState: StateConfig | undefined,
@ -405,6 +441,7 @@ class ButtonCard extends LitElement {
const nameStyleFromConfig = this._buildStyleGeneric(configState, 'name');
const stateStyleFromConfig = this._buildStyleGeneric(configState, 'state');
const labelStyleFromConfig = this._buildStyleGeneric(configState, 'label');
const lastChangedTemplate = this._buildLastChanged(state, labelStyleFromConfig);
if (!iconTemplate) itemClass.push('no-icon');
if (!name) itemClass.push('no-name');
if (!stateString) itemClass.push('no-state');
@ -415,7 +452,8 @@ class ButtonCard extends LitElement {
${iconTemplate ? iconTemplate : ''}
${name ? html`<div class="name" style=${styleMap(nameStyleFromConfig)}>${name}</div>` : ''}
${stateString ? html`<div class="state" style=${styleMap(stateStyleFromConfig)}>${stateString}</div>` : ''}
${label ? html`<div class="label" style=${styleMap(labelStyleFromConfig)}>${label}</div>` : ''}
${label && !this.config!.show_last_changed ? html`<div class="label" style=${styleMap(labelStyleFromConfig)}>${unsafeHTML(label)}</div>` : ''}
${this.config!.show_last_changed ? lastChangedTemplate : ''}
</div>
`;
}
@ -533,4 +571,31 @@ class ButtonCard extends LitElement {
const config = ev.target.config;
handleClick(this, this.hass!, config, true);
}
private _handleLock(ev): void {
ev.stopPropagation();
const overlay = this.shadowRoot!.getElementById('overlay') as LitElement;
const haCard = this.shadowRoot!.firstElementChild as LitElement;
overlay.style.setProperty('pointer-events', 'none');
const paperRipple = document.createElement('paper-ripple');
const lock = this.shadowRoot!.getElementById('lock') as LitElement;
if (lock) {
haCard.appendChild(paperRipple);
const icon = document.createAttribute('icon');
icon.value = 'mdi:lock-open-outline';
lock.attributes.setNamedItem(icon);
lock.classList.add('fadeOut');
}
window.setTimeout(() => {
overlay.style.setProperty('pointer-events', '');
if (lock) {
lock.classList.remove('fadeOut');
const icon = document.createAttribute('icon');
icon.value = 'mdi:lock-outline';
lock.attributes.setNamedItem(icon);
haCard.removeChild(paperRipple);
}
}, 5000);
}
}

View File

@ -1,5 +1,5 @@
import { PropertyValues } from 'lit-element';
import { TinyColor } from '@ctrl/tinycolor';
import tinycolor, { TinyColor } from '@ctrl/tinycolor';
import { HomeAssistant } from './types';
export function computeDomain(entityId: string): string {
@ -27,6 +27,22 @@ export function getFontColorBasedOnBackgroundColor(backgroundColor: string): str
}
}
export function getLightColorBasedOnTemperature(
current: number,
min: number,
max: number,
): string {
const high = new TinyColor('rgb(255, 160, 0)'); // orange-ish
const low = new TinyColor('rgb(166, 209, 255)'); // blue-ish
const middle = new TinyColor('white');
const mixAmount = (current - min) / (max - min) * 100;
if (mixAmount < 50) {
return tinycolor(low).mix(middle, mixAmount * 2).toRgbString();
} else {
return tinycolor(middle).mix(high, (mixAmount - 50) * 2).toRgbString();
}
}
export function buildNameStateConcat(
name: string | undefined, stateString: string | undefined,
): string | undefined {

View File

@ -5,6 +5,7 @@ export const styles = css`
cursor: pointer;
overflow: hidden;
box-sizing: border-box;
position: relative;
}
ha-card.disabled {
pointer-events: none;
@ -29,6 +30,34 @@ export const styles = css`
white-space: nowrap;
overflow: hidden;
}
#overlay {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
text-align: right;
z-index: 1;
}
#lock {
margin-top: 8px;
opacity: 0.5;
margin-right: 7px;
-webkit-animation-duration: 5s;
animation-duration: 5s;
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
}
@keyframes fadeOut{
0% {opacity: 0.5;}
20% {opacity: 0;}
80% {opacity: 0;}
100% {opacity: 0.5;}
}
.fadeOut {
-webkit-animation-name: fadeOut;
animation-name: fadeOut;
}
@keyframes blink{
0%{opacity:0;}
50%{opacity:1;}

View File

@ -16,6 +16,7 @@ export interface ButtonCardConfig {
color_type: 'icon' | 'card' | 'label-card' | 'blank-card'
color?: string;
size: string;
lock: boolean;
tap_action?: ActionConfig;
hold_action?: ActionConfig;
show_name?: boolean;
@ -23,6 +24,7 @@ export interface ButtonCardConfig {
show_icon?: boolean;
show_units?: boolean;
show_entity_picture?: boolean;
show_last_changed?: boolean;
show_label?: boolean;
label?: string;
label_template?: string;