feat: Modernize the visual card editor. (#2586)

This commit is contained in:
Dermot Duffy
2026-07-20 20:44:16 -07:00
committed by GitHub
parent e204ec183f
commit b6e1de5999
146 changed files with 8487 additions and 5992 deletions
+237
View File
@@ -0,0 +1,237 @@
import {
html,
LitElement,
nothing,
unsafeCSS,
type CSSResultGroup,
type TemplateResult,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import type { FormsInput } from '../../components-lib/editor/forms-controller';
import { ListFormsController } from '../../components-lib/editor/list-forms-controller';
import { ListPagesController } from '../../components-lib/editor/list-pages-controller';
import {
getEditorCameraTitle,
getEditorTriggerEventTitle,
} from '../../components-lib/editor/titles';
import type { ConfigPath } from '../../components-lib/editor/types';
import { CONF_CAMERAS } from '../../config/const';
import type { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize';
import editorExpanderBodyStyle from '../../scss/editor-expander-body.scss';
import { renderDocumentation } from './doc-link';
import { renderForms } from './form';
import './../icon';
import './list';
import './page';
const CAMERAS_PATH: ConfigPath = [CONF_CAMERAS];
// The lists the user can drill into, named so a page holding more than one can
// tell which was entered.
const CAMERAS_LIST = 'cameras';
const EVENTS_LIST = 'events';
// The cameras section's content: the list of cameras, and the editor for
// whichever camera (or trigger event within it) the user opened. One level is
// shown at a time, as Home Assistant's own editors do for the items of a list.
@customElement('advanced-camera-card-editor-cameras')
export class AdvancedCameraCardEditorCameras extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public input?: FormsInput;
private _pagesController = new ListPagesController(this);
private _formsController = new ListFormsController(this, (path) =>
renderDocumentation(path),
);
protected willUpdate(): void {
if (this.input) {
this._formsController.setInput(this.input);
}
}
protected render(): TemplateResult {
const cameras = this._formsController.getList(CAMERAS_PATH);
const [camera, subpage] = this._pagesController.getPath();
// A page naming a camera that no longer exists (the configuration was
// edited elsewhere) shows the list instead.
if (camera === undefined || camera.index >= cameras.length) {
return this._renderList(cameras);
}
// A step below the camera names the sub-list it entered; today the only one
// is the events. The camera page stays mounted (hidden) while an event is
// edited, so its expanded panels are still expanded when the user comes
// back to it, rather than being rebuilt collapsed.
const event = subpage?.list === EVENTS_LIST ? subpage.index : null;
return html`
${this._renderCamera(camera.index, event !== null)}
${event !== null ? this._renderEvent(camera.index, event) : nothing}
`;
}
private _renderList(cameras: readonly unknown[]): TemplateResult {
return html`
<advanced-camera-card-editor-list
itemIcon="mdi:video"
.addLabel=${localize('editor.add_new_camera')}
.items=${cameras.map((camera, index) => ({
title: getEditorCameraTitle(index, camera, this.hass),
}))}
@advanced-camera-card:editor:list:item-edit=${(
ev: CustomEvent<{ index: number }>,
) => this._pagesController.open(CAMERAS_LIST, ev.detail.index)}
@advanced-camera-card:editor:list:item-add=${() => {
this._formsController.addItem(CAMERAS_PATH, {});
this._pagesController.open(CAMERAS_LIST, cameras.length);
}}
@advanced-camera-card:editor:list:item-move=${(
ev: CustomEvent<{ from: number; to: number }>,
) => this._formsController.moveItem(CAMERAS_PATH, ev.detail.from, ev.detail.to)}
@advanced-camera-card:editor:list:item-delete=${(
ev: CustomEvent<{ index: number }>,
) => this._formsController.deleteItem(CAMERAS_PATH, ev.detail.index)}
></advanced-camera-card-editor-list>
`;
}
private _renderCamera(index: number, hidden: boolean): TemplateResult {
const camera = this._formsController.getList(CAMERAS_PATH)[index];
const eventsPath: ConfigPath = [CONF_CAMERAS, index, 'triggers', 'events'];
const events = this._formsController.getList(eventsPath);
return html`
<advanced-camera-card-editor-page
?hidden=${hidden}
.heading=${getEditorCameraTitle(index, camera, this.hass)}
@advanced-camera-card:editor:page:back=${() => this._pagesController.back()}
>
${renderForms(
this.hass,
this._formsController.getFormContexts({ kind: 'camera', index }),
)}
<ha-expansion-panel
outlined
.header=${localize('config.cameras.triggers.editor_label')}
>
<advanced-camera-card-icon
slot="leading-icon"
.icon=${{ icon: 'mdi:magnify-scan' }}
></advanced-camera-card-icon>
${this._renderContained(html`
${renderDocumentation([CONF_CAMERAS, 'triggers'])}
${renderForms(
this.hass,
this._formsController.getFormContexts({
kind: 'camera-triggers',
cameraIndex: index,
}),
)}
${this._renderEvents(eventsPath, events)}
`)}
</ha-expansion-panel>
</advanced-camera-card-editor-page>
`;
}
// The Home Assistant events the triggers watch for, a group of their own
// within the triggers panel: they are one kind of trigger among the others,
// not a sibling of the whole trigger set.
private _renderEvents(
eventsPath: ConfigPath,
events: readonly unknown[],
): TemplateResult {
return html`
<ha-expansion-panel
outlined
.header=${localize('config.cameras.triggers.events.editor_label')}
>
<advanced-camera-card-icon
slot="leading-icon"
.icon=${{ icon: 'mdi:home-assistant' }}
></advanced-camera-card-icon>
${this._renderContained(html`
<advanced-camera-card-editor-list
itemIcon="mdi:flash"
.addLabel=${localize('config.cameras.triggers.events.add_new_event')}
.items=${events.map((event, eventIndex) => ({
title: getEditorTriggerEventTitle(eventIndex, event),
}))}
@advanced-camera-card:editor:list:item-edit=${(
ev: CustomEvent<{ index: number }>,
) => this._pagesController.open(EVENTS_LIST, ev.detail.index)}
@advanced-camera-card:editor:list:item-add=${() => {
// An event filter must name an event type. The empty name the new
// item starts with matches nothing until the user fills it in.
this._formsController.addItem(eventsPath, { event_type: '' });
this._pagesController.open(EVENTS_LIST, events.length);
}}
@advanced-camera-card:editor:list:item-move=${(
ev: CustomEvent<{ from: number; to: number }>,
) =>
this._formsController.moveItem(eventsPath, ev.detail.from, ev.detail.to)}
@advanced-camera-card:editor:list:item-delete=${(
ev: CustomEvent<{ index: number }>,
) => this._formsController.deleteItem(eventsPath, ev.detail.index)}
></advanced-camera-card-editor-list>
`)}
</ha-expansion-panel>
`;
}
// A nested panel's expansion and transition events would otherwise reach the
// panel above and disturb the height it animates to, or collapse it.
private _renderContained(content: TemplateResult): TemplateResult {
return html`
<div
class="values"
@transitionend=${this._stopPropagation}
@expanded-will-change=${this._stopPropagation}
@expanded-changed=${this._stopPropagation}
>
${content}
</div>
`;
}
private _stopPropagation(ev: Event): void {
ev.stopPropagation();
}
private _renderEvent(cameraIndex: number, eventIndex: number): TemplateResult {
const eventsPath: ConfigPath = [CONF_CAMERAS, cameraIndex, 'triggers', 'events'];
const event = this._formsController.getList(eventsPath)[eventIndex];
return html`
<advanced-camera-card-editor-page
.heading=${getEditorTriggerEventTitle(eventIndex, event)}
@advanced-camera-card:editor:page:back=${() => this._pagesController.back()}
>
${renderForms(
this.hass,
this._formsController.getFormContexts({
kind: 'camera-event',
cameraIndex,
eventIndex,
}),
)}
</advanced-camera-card-editor-page>
`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(editorExpanderBodyStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-editor-cameras': AdvancedCameraCardEditorCameras;
}
}
+66
View File
@@ -0,0 +1,66 @@
import {
html,
LitElement,
unsafeCSS,
type CSSResultGroup,
type TemplateResult,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { getDocURL } from '../../components-lib/editor/doc-links';
import { localize } from '../../localize/localize';
import editorDocLinkStyle from '../../scss/editor-doc-link.scss';
// A self-styled documentation link row. Carries its own styles so it renders
// correctly when passed into the shadow DOM of Home Assistant elements (e.g. as
// an `ha-form` expandable description), where the editor's stylesheet cannot
// reach.
@customElement('advanced-camera-card-editor-doc-link')
export class AdvancedCameraCardEditorDocLink extends LitElement {
@property({ attribute: false })
public url?: string;
protected render(): TemplateResult | void {
if (!this.url) {
return;
}
return html`
<a
href=${this.url}
target="_blank"
rel="noopener noreferrer"
title=${localize('editor.docs')}
>
<ha-icon icon="mdi:book-open-page-variant"></ha-icon>
<div>${localize('editor.docs')}</div>
<ha-icon icon="mdi:open-in-new"></ha-icon>
</a>
`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(editorDocLinkStyle);
}
}
/**
* Render the documentation link for a configuration path.
* @param path The configuration path.
* @returns A rendered template, or null when the path has no documentation.
*/
export const renderDocumentation = (
path: (string | number)[],
): TemplateResult | null => {
const url = getDocURL(path);
return url
? html`<advanced-camera-card-editor-doc-link
.url=${url}
></advanced-camera-card-editor-doc-link>`
: null;
};
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-editor-doc-link': AdvancedCameraCardEditorDocLink;
}
}
+109
View File
@@ -0,0 +1,109 @@
import {
html,
LitElement,
unsafeCSS,
type CSSResultGroup,
type TemplateResult,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import type { FormsInput } from '../../components-lib/editor/forms-controller';
import { ListFormsController } from '../../components-lib/editor/list-forms-controller';
import { ListPagesController } from '../../components-lib/editor/list-pages-controller';
import { getEditorFolderTitle } from '../../components-lib/editor/titles';
import type { ConfigPath } from '../../components-lib/editor/types';
import { CONF_FOLDERS } from '../../config/const';
import type { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize';
import editorExpanderBodyStyle from '../../scss/editor-expander-body.scss';
import { renderDocumentation } from './doc-link';
import { renderForms } from './form';
import './../icon';
import './list';
import './page';
const FOLDERS_PATH: ConfigPath = [CONF_FOLDERS];
// The single list this section drills into.
const FOLDERS_LIST = 'folders';
// The folders section's content: the list of folders, each with its own form.
@customElement('advanced-camera-card-editor-folders')
export class AdvancedCameraCardEditorFolders extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public input?: FormsInput;
private _pages = new ListPagesController(this);
private _controller = new ListFormsController(this, (path) =>
renderDocumentation(path),
);
protected willUpdate(): void {
if (this.input) {
this._controller.setInput(this.input);
}
}
protected render(): TemplateResult {
const folders = this._controller.getList(FOLDERS_PATH);
const [folder] = this._pages.getPath();
// A page naming a folder that no longer exists (the configuration was
// edited elsewhere) shows the list instead.
if (folder !== undefined && folder.index < folders.length) {
const index = folder.index;
return html`
<advanced-camera-card-editor-page
.heading=${getEditorFolderTitle(index, folders[index])}
@advanced-camera-card:editor:page:back=${() => this._pages.back()}
>
${renderForms(
this.hass,
this._controller.getFormContexts({ kind: 'folder', index }),
)}
<ha-alert alert-type="info">
${localize('config.folders.ha.path_info')}
</ha-alert>
</advanced-camera-card-editor-page>
`;
}
return html`
<advanced-camera-card-editor-list
itemIcon="mdi:folder"
.addLabel=${localize('editor.add_new_folder')}
.items=${folders.map((folder, folderIndex) => ({
title: getEditorFolderTitle(folderIndex, folder),
}))}
@advanced-camera-card:editor:list:item-edit=${(
ev: CustomEvent<{ index: number }>,
) => this._pages.open(FOLDERS_LIST, ev.detail.index)}
@advanced-camera-card:editor:list:item-add=${() => {
this._controller.addItem(FOLDERS_PATH, {});
this._pages.open(FOLDERS_LIST, folders.length);
}}
@advanced-camera-card:editor:list:item-move=${(
ev: CustomEvent<{ from: number; to: number }>,
) => this._controller.moveItem(FOLDERS_PATH, ev.detail.from, ev.detail.to)}
@advanced-camera-card:editor:list:item-delete=${(
ev: CustomEvent<{ index: number }>,
) => this._controller.deleteItem(FOLDERS_PATH, ev.detail.index)}
></advanced-camera-card-editor-list>
`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(editorExpanderBodyStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-editor-folders': AdvancedCameraCardEditorFolders;
}
}
+30
View File
@@ -0,0 +1,30 @@
import { html, type TemplateResult } from 'lit';
import type { FormContext } from '../../components-lib/editor/forms-controller';
import type { HomeAssistant } from '../../ha/types';
/**
* Render the `ha-form`s of a set of form contexts. Each context is passed
* through as it stands: `ha-form` compares its inputs by identity, so
* substituting an equivalent object for any of them re-renders every field.
* @param hass The HomeAssistant object.
* @param contexts The contexts to render.
* @returns A rendered template.
*/
export const renderForms = (
hass: HomeAssistant | undefined,
contexts: FormContext[],
): TemplateResult => {
return html`${contexts.map(
(context) => html`
<ha-form
.hass=${hass}
.data=${context.displayedData}
.schema=${context.form.schema}
.computeLabel=${context.computeLabel}
.computeHelper=${context.computeHelper}
@value-changed=${context.valueChanged}
></ha-form>
`,
)}`;
};
@@ -0,0 +1,86 @@
import {
html,
LitElement,
unsafeCSS,
type CSSResultGroup,
type TemplateResult,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { getLocalizationKeyForPath } from '../../components-lib/editor/form-labels.js';
import type { FormsInput } from '../../components-lib/editor/forms-controller.js';
import { KeyboardShortcutsController } from '../../components-lib/editor/keyboard-shortcuts-controller.js';
import { CONF_VIEW_KEYBOARD_SHORTCUTS } from '../../config/const.js';
import type { KeyboardShortcut } from '../../config/schema/view.js';
import type { HomeAssistant } from '../../ha/types.js';
import { localize } from '../../localize/localize.js';
import editorKeyboardShortcutsStyle from '../../scss/editor-keyboard-shortcuts.scss';
import { renderDocumentation } from './doc-link.js';
import { renderForms } from './form.js';
import './../icon';
import './../key-assigner';
// The keyboard shortcuts panel. Hand-built rather than an `ha-form` expandable
// because a shortcut is assigned by pressing a key, which no selector can
// express.
@customElement('advanced-camera-card-editor-keyboard-shortcuts')
export class AdvancedCameraCardEditorKeyboardShortcuts extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public input?: FormsInput;
private _controller = new KeyboardShortcutsController(this, (path) =>
renderDocumentation(path),
);
protected willUpdate(): void {
if (this.input) {
this._controller.setInput(this.input);
}
}
protected render(): TemplateResult {
return html`
<ha-expansion-panel outlined>
<advanced-camera-card-icon
slot="leading-icon"
.icon=${{ icon: 'mdi:keyboard' }}
></advanced-camera-card-icon>
<span slot="header"
>${localize('config.view.keyboard_shortcuts.editor_label')}</span
>
<div class="values" @transitionend=${(ev: Event) => ev.stopPropagation()}>
${renderDocumentation(CONF_VIEW_KEYBOARD_SHORTCUTS.split('.'))}
${renderForms(this.hass, this._controller.getContexts())}
${Object.keys(this._controller.getShortcuts()).map((name) =>
this._renderKeyAssigner(name),
)}
</div>
</ha-expansion-panel>
`;
}
private _renderKeyAssigner(name: string): TemplateResult {
return html`<advanced-camera-card-key-assigner
.label=${localize(
getLocalizationKeyForPath([...CONF_VIEW_KEYBOARD_SHORTCUTS.split('.'), name]),
)}
.value=${this._controller.getShortcuts()[name]}
@value-changed=${(ev: CustomEvent<{ value: KeyboardShortcut | null }>) =>
this._controller.setShortcut(name, ev.detail.value)}
></advanced-camera-card-key-assigner>`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(editorKeyboardShortcutsStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-editor-keyboard-shortcuts': AdvancedCameraCardEditorKeyboardShortcuts;
}
}
+136
View File
@@ -0,0 +1,136 @@
import {
html,
LitElement,
nothing,
unsafeCSS,
type CSSResultGroup,
type TemplateResult,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { localize } from '../../localize/localize';
import editorListStyle from '../../scss/editor-list.scss';
import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event';
import './../icon';
// One item of the list: what to call it, and a line below that naming what it
// refers to where the title alone does not say.
export interface EditorListItem {
title: string;
description?: string;
}
// A list of configuration items, rendered as the rows Home Assistant uses for
// the editable lists in its own editors. An item is opened with the row's edit
// control, which reports `item-edit` for the owner to show that item's editor
// in place of the list. Reordering is done by dragging a row's handle.
//
// The events do not bubble beyond the owner: they are composed only as far as
// the list's own host, since the editor above interprets nothing between here
// and itself.
@customElement('advanced-camera-card-editor-list')
export class AdvancedCameraCardEditorList extends LitElement {
// The items, in list order. Their configuration is not passed: the owner
// renders each item's editor when asked.
@property({ attribute: false })
public items: EditorListItem[] = [];
@property()
public itemIcon?: string;
@property()
public addLabel?: string;
protected render(): TemplateResult {
return html`
<ha-sortable
handle-selector=".handle"
draggable-selector=".item"
@item-moved=${(ev: CustomEvent<{ oldIndex: number; newIndex: number }>) => {
ev.stopPropagation();
this._fire('item-move', { from: ev.detail.oldIndex, to: ev.detail.newIndex });
}}
>
<ha-md-list>
${this.items.map((item, index) => this._renderItem(item, index))}
</ha-md-list>
</ha-sortable>
${this._renderAdd()}
`;
}
private _renderItem(item: EditorListItem, index: number): TemplateResult {
return html`
<ha-md-list-item class="item">
<advanced-camera-card-icon
slot="start"
class="handle"
.icon=${{ icon: 'mdi:drag-horizontal-variant' }}
></advanced-camera-card-icon>
<advanced-camera-card-icon
slot="start"
.icon=${{ icon: this.itemIcon }}
></advanced-camera-card-icon>
<div slot="headline" class="title">${item.title}</div>
${item.description
? html`<div slot="supporting-text" class="description">
${item.description}
</div>`
: nothing}
${this._renderControl('mdi:pencil', localize('editor.edit'), () =>
this._fire('item-edit', { index }),
)}
${this._renderControl('mdi:delete', localize('editor.delete'), () =>
this._fire('item-delete', { index }),
)}
</ha-md-list-item>
`;
}
private _renderAdd(): TemplateResult {
return html`
<ha-button
class="add"
appearance="filled"
size="s"
@click=${() => this._fire('item-add', {})}
>
<advanced-camera-card-icon
slot="start"
.icon=${{ icon: 'mdi:plus' }}
></advanced-camera-card-icon>
${this.addLabel}
</ha-button>
`;
}
private _renderControl(
icon: string,
label: string,
action: () => void,
): TemplateResult {
return html`
<ha-icon-button slot="end" .label=${label} title=${label} @click=${action}>
<advanced-camera-card-icon .icon=${{ icon }}></advanced-camera-card-icon>
</ha-icon-button>
`;
}
private _fire(name: string, detail: Record<string, number>): void {
fireAdvancedCameraCardEvent(this, `editor:list:${name}`, detail, {
bubbles: true,
composed: false,
});
}
static get styles(): CSSResultGroup {
return unsafeCSS(editorListStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-editor-list': AdvancedCameraCardEditorList;
}
}
+60
View File
@@ -0,0 +1,60 @@
import {
html,
LitElement,
unsafeCSS,
type CSSResultGroup,
type PropertyValues,
type TemplateResult,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { localize } from '../../localize/localize';
import editorPageStyle from '../../scss/editor-page.scss';
import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event';
// The editor for one item of a list, shown in place of the list itself: a
// heading naming the item, a control to go back to the list, and the item's
// own content.
@customElement('advanced-camera-card-editor-page')
export class AdvancedCameraCardEditorPage extends LitElement {
@property()
public heading?: string;
protected updated(changedProps: PropertyValues): void {
// Opening an item replaces a list the user may have scrolled down into, so
// the new page can begin above the viewport. Bring its top into view. The
// heading changes on the first render and on each further drill-in, which
// is exactly when a fresh page has been shown. The scroll waits a frame so
// the replaced content has been laid out and the target position is final.
if (changedProps.has('heading')) {
requestAnimationFrame(() => this.scrollIntoView({ block: 'start' }));
}
}
protected render(): TemplateResult {
return html`
<div class="header">
<ha-icon-button-prev
.label=${localize('editor.back')}
@click=${() =>
fireAdvancedCameraCardEvent(this, 'editor:page:back', undefined, {
bubbles: true,
composed: false,
})}
></ha-icon-button-prev>
<span class="heading">${this.heading}</span>
</div>
<slot></slot>
`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(editorPageStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-editor-page': AdvancedCameraCardEditorPage;
}
}
+123
View File
@@ -0,0 +1,123 @@
import {
html,
LitElement,
nothing,
unsafeCSS,
type CSSResultGroup,
type TemplateResult,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import type {
FormContext,
FormsInput,
} from '../../components-lib/editor/forms-controller';
import type { FormRequest } from '../../components-lib/editor/schema/registry';
import { SectionController } from '../../components-lib/editor/section-controller';
import type { HomeAssistant } from '../../ha/types';
import editorSectionStyle from '../../scss/editor-section.scss';
import { renderDocumentation } from './doc-link';
import { renderForms } from './form';
import './../icon';
// One top-level section of the editor: a panel that shows the forms of its part
// of the configuration, and optionally extra custom content.
@customElement('advanced-camera-card-editor-section')
export class AdvancedCameraCardEditorSection extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public request?: FormRequest;
@property({ attribute: false })
public input?: FormsInput;
@property()
public icon?: string;
@property()
public heading?: string;
// The line shown under the heading, describing what the section covers.
@property()
public description?: string;
// The path whose documentation the section links to; the section's own forms
// supply the links for everything within them.
@property({ attribute: false })
public documentationPath?: (string | number)[];
// Content shown after the section's schema forms, of which there may be
// none. Called only once the section has been opened, and in the render that
// opens it, so that the panel measures a height that includes it.
@property({ attribute: false })
public renderCustomContent?: () => TemplateResult;
private _controller = new SectionController(this, (path) => renderDocumentation(path));
protected willUpdate(): void {
if (this.request && this.input) {
this._controller.setInput(this.request, this.input);
}
}
protected render(): TemplateResult {
return html`
<ha-expansion-panel
.outlined=${this._controller.isOpen()}
.header=${this.heading}
.secondary=${this.description}
@expanded-will-change=${(ev: CustomEvent<{ expanded: boolean }>) => {
// Panels nested in the body emit the same event, and it crosses
// shadow boundaries; only this panel's own toggles count.
if (ev.target === ev.currentTarget) {
this._controller.setOpen(ev.detail.expanded);
}
}}
>
<advanced-camera-card-icon
slot="leading-icon"
.icon=${{ icon: this.icon }}
></advanced-camera-card-icon>
${this._controller.wasEverOpened() ? this._renderBody() : nothing}
</ha-expansion-panel>
`;
}
private _renderBody(): TemplateResult {
// Nested panels' expansion and transition events would otherwise reach the
// panel above and disturb the height it animates to, or collapse it.
return html`
<div
class="values"
@transitionend=${this._stopPropagation}
@expanded-will-change=${this._stopPropagation}
@expanded-changed=${this._stopPropagation}
>
${this.documentationPath ? renderDocumentation(this.documentationPath) : nothing}
${renderForms(this.hass, this._controller.getContexts())}
${this.renderCustomContent?.() ?? nothing}
</div>
`;
}
private _stopPropagation(ev: Event): void {
ev.stopPropagation();
}
public getContexts(): FormContext[] {
return this._controller.getContexts();
}
static get styles(): CSSResultGroup {
return unsafeCSS(editorSectionStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-editor-section': AdvancedCameraCardEditorSection;
}
}
+6 -3
View File
@@ -1,8 +1,7 @@
import { html, unsafeCSS, type CSSResultGroup, type TemplateResult } from 'lit';
import { customElement } from 'lit/decorators.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import checkCircleSVG from '../../images/check-circle.svg';
import checkCircleIcon from '../../images/check-circle.svg';
import checkStyle from '../../scss/check.scss';
import { BaseEffectComponent } from './base';
@@ -10,7 +9,11 @@ import { BaseEffectComponent } from './base';
export class AdvancedCameraCardEffectCheck extends BaseEffectComponent {
protected render(): TemplateResult {
// Using inline SVG to avoid ha-icon lazy-loading delay on first use.
return html`<span class="check">${unsafeHTML(checkCircleSVG)}</span>`;
return html`<span class="check">
<svg viewBox=${checkCircleIcon.viewBox} fill="currentColor">
<path d=${checkCircleIcon.path}></path>
</svg>
</span>`;
}
static get styles(): CSSResultGroup {
+4 -23
View File
@@ -3,7 +3,6 @@ import {
LitElement,
unsafeCSS,
type CSSResultGroup,
type PropertyValues,
type TemplateResult,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
@@ -31,27 +30,9 @@ export class AdvancedCameraCardIcon extends LitElement {
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 {
if (this._svg) {
// Use SVG objects (rather than <img>) to ensure styling applies
// correctly.
return html`${this._svg}`;
}
const iconName = this._controller.getIconName(this.icon);
if (this.hass && this.icon?.entity) {
const stateObj = this._controller.createStateObjectForStateBadge(
this.hass,
@@ -65,12 +46,12 @@ export class AdvancedCameraCardIcon extends LitElement {
.stateColor=${this.icon.stateColor ?? true}
.hass=${this.hass}
.stateObj=${stateObj}
.overrideIcon=${this.icon.icon}
.overrideIcon=${iconName ?? undefined}
></state-badge>`;
}
}
if (this.icon?.icon) {
return html`<ha-icon icon="${this.icon.icon}"></ha-icon>`;
if (iconName) {
return html`<ha-icon icon="${iconName}"></ha-icon>`;
}
if (this.icon?.fallback) {
return html`<ha-icon icon="${this.icon.fallback}"></ha-icon>`;
+3 -1
View File
@@ -27,7 +27,7 @@ export class AdvancedCameraCardKeyAssigner extends LitElement {
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('value')) {
this._controller.setValue(this.value ?? null);
this._controller.showValue(this.value ?? null);
}
}
@@ -44,6 +44,7 @@ export class AdvancedCameraCardKeyAssigner extends LitElement {
return html`
<div class="label">${this.label}</div>
<div class="buttons">
<ha-button
title="${localize('key_assigner.assign')}"
aria-label="${localize('key_assigner.assign')}"
@@ -71,6 +72,7 @@ export class AdvancedCameraCardKeyAssigner extends LitElement {
</ha-button>`
: ''
}
</div>
<div class="key-row">
${this.value?.ctrl ? renderKey(localize('key_assigner.modifiers.ctrl')) : ''}
${this.value?.shift ? renderKey(localize('key_assigner.modifiers.shift')) : ''}
+1 -1
View File
@@ -81,7 +81,7 @@ export class AdvancedCameraCardLoading extends LitElement {
protected render(): TemplateResult {
return html`<advanced-camera-card-icon
.icon=${{ icon: 'iris' }}
.icon=${{ icon: 'advanced-camera-card:iris' }}
></advanced-camera-card-icon
><span>${getReleaseVersion()}</span>`;
}
+2 -2
View File
@@ -22,7 +22,7 @@ import {
} from '../components-lib/timeline/types';
import type { ConditionStateManagerReadonlyInterface } from '../condition-trigger/conditions/types';
import type { ThumbnailsControlBaseConfig } from '../config/schema/common/controls/thumbnails';
import type { TimelineCoreConfig } from '../config/schema/common/controls/timeline';
import type { TimelineCoreComponentConfig } from '../config/schema/common/controls/timeline';
import type { CardWideConfig } from '../config/schema/types';
import type { HomeAssistant } from '../ha/types';
import { localize } from '../localize/localize';
@@ -115,7 +115,7 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false, hasChanged: contentsChanged })
public timelineConfig?: TimelineCoreConfig;
public timelineConfig?: TimelineCoreComponentConfig;
@property({ attribute: false })
public thumbnailConfig?: ThumbnailsControlBaseConfig;