feat: Add richer card theme support (#1809)

This commit is contained in:
Dermot Duffy
2025-01-07 19:54:13 -08:00
committed by GitHub
parent f7ecb91578
commit 278560b5ac
65 changed files with 1187 additions and 352 deletions
+40 -13
View File
@@ -1,7 +1,13 @@
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { IconController } from '../components-lib/icon-controller';
import iconStyle from '../scss/icon.scss';
import { Icon } from '../types';
@@ -14,17 +20,35 @@ export class FrigateCardIcon extends LitElement {
@property({ attribute: false })
public icon?: Icon;
// Note: This attribute will allow non-active entity state styles (e.g. 'off',
// 'unavailable') to be overriden from outside the icon itself. This is useful
// in the menu / submenus where we want icons to follow menu theming, unless
// they are 'active'. This attribute is not used in code, but matched in
// icon.scss .
@property({ attribute: 'allow-override-non-active-styles', type: Boolean })
public allowOverrideNonActiveStyles = false;
private _controller = new IconController();
private _svg: HTMLElement | null = null;
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('icon')) {
const customIcon = this._controller.getCustomIcon(this.icon);
if (customIcon) {
const svgElement = document.createElement('svg');
svgElement.innerHTML = customIcon;
this._svg = svgElement;
} else {
this._svg = null;
}
}
}
protected render(): TemplateResult {
const customIconURL = this._controller.getCustomIcon(this.icon);
const style = styleMap(this.icon?.style || {});
if (customIconURL) {
return html`<img style="${style}" src="${customIconURL}" />`;
}
if (this.icon?.icon) {
return html`<ha-icon style="${style}" icon="${this.icon.icon}"></ha-icon>`;
if (this._svg) {
// Use SVG objects (rather than <img>) to ensure styling applies
// correctly.
return html`${this._svg}`;
}
if (this.hass && this.icon?.entity) {
const stateObj = this._controller.createStateObjectForStateBadge(
@@ -35,16 +59,19 @@ export class FrigateCardIcon extends LitElement {
// As a special case, need to pass in a color in order for the state
// color to be overridden.
return html`<state-badge
style="${style}"
.color="${this.icon.style?.color ?? undefined}"
.color="${this.style.color ?? undefined}"
.stateColor=${this.icon.stateColor ?? true}
.hass=${this.hass}
.stateObj=${stateObj}
.overrideIcon=${this.icon.icon}
></state-badge>`;
}
}
if (this.icon?.icon) {
return html`<ha-icon icon="${this.icon.icon}"></ha-icon>`;
}
if (this.icon?.fallback) {
return html`<ha-icon style="${style}" icon="${this.icon.fallback}"></ha-icon>`;
return html`<ha-icon icon="${this.icon.fallback}"></ha-icon>`;
}
return html``;
}
+2 -2
View File
@@ -1,12 +1,12 @@
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators.js';
import irisLogo from '../images/camera-iris-transparent.svg';
import loadingStyle from '../scss/loading.scss';
import './icon';
@customElement('frigate-card-loading')
export class FrigateCardLoading extends LitElement {
protected render(): TemplateResult {
return html` <img src="${irisLogo}" /> `;
return html`<frigate-card-icon .icon=${{ icon: 'iris' }}></frigate-card-icon>`;
}
static get styles(): CSSResultGroup {
+55 -4
View File
@@ -66,8 +66,6 @@ export class FrigateCardMenu extends LitElement {
: button.title;
return html` <ha-icon-button
class="button"
style="${styleMap(button.style || {})}"
.actionHandler=${actionHandler({
hasHold: frigateCardHasAction(button.hold_action),
hasDoubleClick: frigateCardHasAction(button.double_tap_action),
@@ -76,18 +74,70 @@ export class FrigateCardMenu extends LitElement {
@action=${(ev) => this._controller.actionHandler(ev, button)}
>
<frigate-card-icon
?allow-override-non-active-styles=${true}
style="${styleMap(button.style || {})}"
.hass=${this.hass}
.icon=${{
icon: button.icon,
entity: button.entity,
stateColor: button.state_color,
style: button.style,
fallback: 'mdi:gesture-tap-button',
}}
></frigate-card-icon>
</ha-icon-button>`;
}
/** Theme-related styling is dynamically injected into the menu depending on
* the configured position, style and alignment to allow precise theming.
* The alternative is a massive (post-sass processing) CSS file would need to
* be shipped to account for every possible combination.
*
* Each rule uses 'var' values that have nested fallbacks of decreasing
* specificity, so the most specific theme variable will match, followed by
* the next most specific, etc.
*/
protected _renderPerInstanceStyle(): TemplateResult | void {
const config = this._controller.getMenuConfig();
if (!config) {
return;
}
const position = config.position;
const style = config.style;
const alignment = config.alignment;
const generateValue = (suffix: string): string => {
return `
var(--frigate-card-menu-override-${suffix},
var(--frigate-card-menu-position-${position}-alignment-${alignment}-style-${style}-${suffix},
var(--frigate-card-menu-position-${position}-alignment-${alignment}-${suffix},
var(--frigate-card-menu-position-${position}-${suffix},
var(--frigate-card-menu-style-${style}-${suffix},
var(--frigate-card-menu-alignment-${alignment}-${suffix},
var(--frigate-card-menu-${suffix})))))))`;
};
// By definition `rule` will match the current configuration, the choice is
// actually which of the var(...) variables will be used after the match.
const expandedRule = style === 'hidden' ? '[expanded]' : '';
const rule =
`[data-position='${position}']` +
`[data-style='${style}']` +
`[data-alignment='${alignment}']` +
expandedRule;
return html`<style>
:host(${rule}) {
background: ${generateValue('background')};
ha-icon-button {
color: ${generateValue('button-inactive-color')};
background: ${generateValue('button-background')};
}
}
</style>`;
}
protected render(): TemplateResult | void {
const config = this._controller.getMenuConfig();
const style = config?.style;
@@ -97,7 +147,8 @@ export class FrigateCardMenu extends LitElement {
const matchingButtons = this._controller.getButtons('matching');
const opposingButtons = this._controller.getButtons('opposing');
return html` <div
return html` ${this._renderPerInstanceStyle()}
<div
class="matching"
style="${styleMap({ flex: String(matchingButtons.length) })}"
>
-1
View File
@@ -63,7 +63,6 @@ export class FrigateCardNextPreviousControl extends LitElement {
right: this.side === 'right',
thumbnails: !renderIcon,
icons: renderIcon,
button: renderIcon,
};
if (renderIcon) {
+36
View File
@@ -36,12 +36,48 @@ export class FrigateCardStatusBar extends LitElement {
}
}
/** Theme-related styling is dynamically injected into the status bar depending on
* the configured position and style to allow precise theming.
*
* Each rule uses 'var' values that have nested fallbacks of decreasing
* specificity, so the most specific theme variable will match, followed by
* the next most specific, etc.
*/
protected _renderPerInstanceStyle(): TemplateResult | void {
const config = this._controller.getConfig();
if (!config) {
return;
}
const position = config.position;
const style = config.style;
const generateValue = (suffix: string): string => {
return `
var(--frigate-card-status-bar-override-${suffix},
var(--frigate-card-status-bar-position-${position}-style-${style}-${suffix},
var(--frigate-card-status-bar-position-${position}-${suffix},
var(--frigate-card-status-bar-style-${style}-${suffix},
var(--frigate-card-status-bar-${suffix})))))`;
};
const rule = `[data-position='${position}']` + `[data-style='${style}']`;
return html`<style>
:host(${rule}) {
color: ${generateValue('color')};
background: ${generateValue('background')};
}
</style>`;
}
protected render(): TemplateResult | void {
if (!this._controller.shouldRender()) {
return;
}
return html`
${this._renderPerInstanceStyle()}
<div class="status">
${this._controller.getRenderItems().map((item): TemplateResult | void => {
if (item.enabled === false) {
+7 -7
View File
@@ -43,9 +43,9 @@ export class FrigateCardSubmenu extends LitElement {
}
const title = item.title ?? getEntityTitle(this.hass, item.entity);
const style = styleMap(item.style || {});
return html`
<mwc-list-item
style="${styleMap(item.style || {})}"
graphic=${ifDefined(item.icon || item.entity ? 'icon' : undefined)}
?twoline=${!!item.subtitle}
?selected=${item.selected}
@@ -61,17 +61,18 @@ export class FrigateCardSubmenu extends LitElement {
hasDoubleClick: frigateCardHasAction(item.double_tap_action),
})}
>
<span>${title ?? ''}</span>
${item.subtitle ? html`<span slot="secondary">${item.subtitle}</span>` : ''}
<span style="${style}">${title ?? ''}</span>
${item.subtitle
? html`<span slot="secondary" style="${style}">${item.subtitle}</span>`
: ''}
<frigate-card-icon
slot="graphic"
.hass=${this.hass}
.icon=${{
icon: item.icon,
entity: item.entity,
style: item.style,
}}
style="${styleMap(item.style || {})}"
style="${style}"
></frigate-card-icon>
</mwc-list-item>
`;
@@ -96,7 +97,6 @@ export class FrigateCardSubmenu extends LitElement {
>
<ha-icon-button
style="${style}"
class="button"
slot="trigger"
.label=${this.submenu.title || ''}
.actionHandler=${actionHandler({
@@ -110,6 +110,7 @@ export class FrigateCardSubmenu extends LitElement {
})}
>
<frigate-card-icon
?allow-override-non-active-styles=${true}
style="${style}"
.hass=${this.hass}
.icon=${typeof this.submenu.icon === 'string'
@@ -217,7 +218,6 @@ export class FrigateCardSubmenuSelect extends LitElement {
icon: this.submenuSelect.icon,
entity: entityID,
fallback: 'mdi:format-list-bulleted',
style: this.submenuSelect.style,
},
type: 'custom:frigate-card-menu-submenu',