feat: Modernize the visual card editor. (#2586)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>`;
|
||||
|
||||
@@ -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')) : ''}
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user