Compare commits

..

12 Commits

Author SHA1 Message Date
Aidan Timson
4e6fb3c103 Scale small based on date length 2026-01-13 10:52:35 +00:00
Aidan Timson
4b8cf9a056 Sizing (CSS Impl) 2026-01-13 10:40:36 +00:00
Aidan Timson
bcac688538 Sizing (JS Impl) 2026-01-13 10:40:09 +00:00
Aidan Timson
616209c0f0 Format 2026-01-13 08:57:33 +00:00
Aidan Timson
21ba3952d9 Add 2026-01-13 08:57:33 +00:00
Aidan Timson
7392e05230 Improve 2026-01-13 08:57:33 +00:00
Aidan Timson
74de8365eb Type 2026-01-13 08:57:33 +00:00
Aidan Timson
999d54147d Setup 2026-01-13 08:57:33 +00:00
Aidan Timson
721d0237ac Match 2026-01-13 08:57:33 +00:00
Aidan Timson
a3ad223230 Setup analog clock 2026-01-13 08:57:33 +00:00
Aidan Timson
69290a2ffb Add date to digital clock 2026-01-13 08:57:33 +00:00
Aidan Timson
c840af63f5 Setup 2026-01-13 08:57:33 +00:00
27 changed files with 566 additions and 452 deletions

2
.gitignore vendored
View File

@@ -15,7 +15,7 @@ dist/
!.yarn/sdks
!.yarn/versions
.pnp.*
node_modules/
/node_modules/
yarn-error.log
npm-debug.log

2
.nvmrc
View File

@@ -1 +1 @@
24.13.0
24.12.0

View File

@@ -236,6 +236,6 @@
},
"packageManager": "yarn@4.12.0",
"volta": {
"node": "24.13.0"
"node": "24.12.0"
}
}

View File

@@ -1,16 +1,6 @@
// From https://github.com/epoberezkin/fast-deep-equal
// MIT License - Copyright (c) 2017 Evgeny Poberezkin
interface DeepEqualOptions {
/** Compare Symbol properties in addition to string keys */
compareSymbols?: boolean;
}
export const deepEqual = (
a: any,
b: any,
options?: DeepEqualOptions
): boolean => {
export const deepEqual = (a: any, b: any): boolean => {
if (a === b) {
return true;
}
@@ -28,7 +18,7 @@ export const deepEqual = (
return false;
}
for (i = length; i-- !== 0; ) {
if (!deepEqual(a[i], b[i], options)) {
if (!deepEqual(a[i], b[i])) {
return false;
}
}
@@ -45,7 +35,7 @@ export const deepEqual = (
}
}
for (i of a.entries()) {
if (!deepEqual(i[1], b.get(i[0]), options)) {
if (!deepEqual(i[1], b.get(i[0]))) {
return false;
}
}
@@ -103,28 +93,11 @@ export const deepEqual = (
for (i = length; i-- !== 0; ) {
const key = keys[i];
if (!deepEqual(a[key], b[key], options)) {
if (!deepEqual(a[key], b[key])) {
return false;
}
}
// Compare Symbol properties if requested
if (options?.compareSymbols) {
const symbolsA = Object.getOwnPropertySymbols(a);
const symbolsB = Object.getOwnPropertySymbols(b);
if (symbolsA.length !== symbolsB.length) {
return false;
}
for (const sym of symbolsA) {
if (!Object.prototype.hasOwnProperty.call(b, sym)) {
return false;
}
if (!deepEqual(a[sym], b[sym], options)) {
return false;
}
}
}
return true;
}

View File

@@ -1,9 +1,8 @@
import { consume } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { fullEntitiesContext } from "../../data/context";
import type { DeviceAutomation } from "../../data/device/device_automation";
import {
@@ -12,12 +11,11 @@ import {
} from "../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import type { HomeAssistant } from "../../types";
import "../ha-generic-picker";
import "../ha-md-select";
import "../ha-md-select-option";
import type { PickerValueRenderer } from "../ha-picker-field";
const NO_AUTOMATION_KEY = "NO_AUTOMATION";
const UNKNOWN_AUTOMATION_KEY = "UNKNOWN_AUTOMATION";
export abstract class HaDeviceAutomationPicker<
T extends DeviceAutomation,
@@ -30,7 +28,7 @@ export abstract class HaDeviceAutomationPicker<
@property({ type: Object }) public value?: T;
@state() private _automations?: T[];
@state() private _automations: T[] = [];
// Trigger an empty render so we start with a clean DOM.
// paper-listbox does not like changing things around.
@@ -46,6 +44,12 @@ export abstract class HaDeviceAutomationPicker<
);
}
protected get UNKNOWN_AUTOMATION_TEXT() {
return this.hass.localize(
"ui.panel.config.devices.automation.actions.unknown_action"
);
}
private _localizeDeviceAutomation: (
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
@@ -71,7 +75,7 @@ export abstract class HaDeviceAutomationPicker<
}
private get _value() {
if (!this.value || !this._automations) {
if (!this.value) {
return "";
}
@@ -84,7 +88,7 @@ export abstract class HaDeviceAutomationPicker<
);
if (idx === -1) {
return this.value.alias || this.value.type || "unknown";
return UNKNOWN_AUTOMATION_KEY;
}
return `${this._automations[idx].device_id}_${idx}`;
@@ -95,21 +99,37 @@ export abstract class HaDeviceAutomationPicker<
return nothing;
}
const value = this._value;
return html`<ha-generic-picker
.hass=${this.hass}
.label=${this.label}
.value=${value}
.disabled=${!this._automations || this._automations.length === 0}
.getItems=${this._getItems(value, this._automations)}
@value-changed=${this._automationChanged}
.valueRenderer=${this._valueRenderer}
.unknownItemText=${this.hass.localize(
"ui.panel.config.devices.automation.actions.unknown_action"
)}
hide-clear-icon
>
</ha-generic-picker>`;
return html`
<ha-md-select
.label=${this.label}
.value=${value}
@change=${this._automationChanged}
@closed=${stopPropagation}
.disabled=${this._automations.length === 0}
>
${value === NO_AUTOMATION_KEY
? html`<ha-md-select-option .value=${NO_AUTOMATION_KEY}>
${this.NO_AUTOMATION_TEXT}
</ha-md-select-option>`
: nothing}
${value === UNKNOWN_AUTOMATION_KEY
? html`<ha-md-select-option .value=${UNKNOWN_AUTOMATION_KEY}>
${this.UNKNOWN_AUTOMATION_TEXT}
</ha-md-select-option>`
: nothing}
${this._automations.map(
(automation, idx) => html`
<ha-md-select-option .value=${`${automation.device_id}_${idx}`}>
${this._localizeDeviceAutomation(
this.hass,
this._entityReg,
automation
)}
</ha-md-select-option>
`
)}
</ha-md-select>
`;
}
protected updated(changedProps) {
@@ -120,57 +140,6 @@ export abstract class HaDeviceAutomationPicker<
}
}
private _getItems = memoizeOne(
(value: string, automations: T[] | undefined) => {
if (!automations) {
return () => undefined;
}
const automationListItems = automations.map((automation, idx) => {
const primary = this._localizeDeviceAutomation(
this.hass,
this._entityReg,
automation
);
return {
id: `${automation.device_id}_${idx}`,
primary,
};
});
automationListItems.sort((a, b) =>
caseInsensitiveStringCompare(
a.primary,
b.primary,
this.hass.locale.language
)
);
if (value === NO_AUTOMATION_KEY) {
automationListItems.unshift({
id: NO_AUTOMATION_KEY,
primary: this.NO_AUTOMATION_TEXT,
});
}
return () => automationListItems;
}
);
private _valueRenderer: PickerValueRenderer = (value: string) => {
const automation = this._automations?.find(
(a, idx) => value === `${a.device_id}_${idx}`
);
const text = automation
? this._localizeDeviceAutomation(this.hass, this._entityReg, automation)
: value === NO_AUTOMATION_KEY
? this.NO_AUTOMATION_TEXT
: value;
return html`<span slot="headline">${text}</span>`;
};
private async _updateDeviceInfo() {
this._automations = this.deviceId
? (await this._fetchDeviceAutomations(this.hass, this.deviceId)).sort(
@@ -192,14 +161,13 @@ export abstract class HaDeviceAutomationPicker<
this._renderEmpty = false;
}
private _automationChanged(ev: CustomEvent<{ value: string }>) {
ev.stopPropagation();
const value = ev.detail.value;
if (!value || NO_AUTOMATION_KEY === value) {
private _automationChanged(ev) {
const value = ev.target.value;
if (!value || [UNKNOWN_AUTOMATION_KEY, NO_AUTOMATION_KEY].includes(value)) {
return;
}
const [deviceId, idx] = value.split("_");
const automation = this._automations![idx];
const automation = this._automations[idx];
if (automation.device_id !== deviceId) {
return;
}

View File

@@ -255,7 +255,6 @@ export class HaCodeEditor extends ReactiveElement {
...this._loadedCodeMirror.tabKeyBindings,
saveKeyBinding,
]),
this._loadedCodeMirror.search({ top: true }),
this._loadedCodeMirror.langCompartment.of(this._mode),
this._loadedCodeMirror.haTheme,
this._loadedCodeMirror.haSyntaxHighlighting,

View File

@@ -1,4 +1,4 @@
import { mdiPlus } from "@mdi/js";
import { mdiLabel, mdiPlus } from "@mdi/js";
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, html } from "lit";
@@ -25,9 +25,11 @@ import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon";
const ADD_NEW_ID = "___ADD_NEW___";
const NO_LABELS = "___NO_LABELS___";
@customElement("ha-label-picker")
export class HaLabelPicker extends SubscribeMixin(LitElement) {
@@ -106,10 +108,52 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
];
}
private _labelMap = memoizeOne(
(
labels: LabelRegistryEntry[] | undefined
): Map<string, LabelRegistryEntry> => {
if (!labels) {
return new Map();
}
return new Map(labels.map((label) => [label.label_id, label]));
}
);
private _computeValueRenderer = memoizeOne(
(labels: LabelRegistryEntry[] | undefined): PickerValueRenderer =>
(value) => {
const label = this._labelMap(labels).get(value);
if (!label) {
return html`
<ha-svg-icon slot="start" .path=${mdiLabel}></ha-svg-icon>
<span slot="headline">${value}</span>
`;
}
return html`
${label.icon
? html`<ha-icon slot="start" .icon=${label.icon}></ha-icon>`
: html`<ha-svg-icon slot="start" .path=${mdiLabel}></ha-svg-icon>`}
<span slot="headline">${label.name}</span>
`;
}
);
private _getLabelsMemoized = memoizeOne(getLabels);
private _getItems = () =>
this._getLabelsMemoized(
private _getItems = () => {
if (!this._labels || this._labels.length === 0) {
return [
{
id: NO_LABELS,
primary: this.hass.localize("ui.components.label-picker.no_labels"),
icon_path: mdiLabel,
},
];
}
return this._getLabelsMemoized(
this.hass.states,
this.hass.areas,
this.hass.devices,
@@ -122,6 +166,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
this.entityFilter,
this.excludeLabels
);
};
private _allLabelNames = memoizeOne((labels?: LabelRegistryEntry[]) => {
if (!labels) {
@@ -174,6 +219,8 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
this.placeholder ??
this.hass.localize("ui.components.label-picker.label");
const valueRenderer = this._computeValueRenderer(this._labels);
return html`
<ha-generic-picker
.disabled=${this.disabled}
@@ -190,6 +237,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
.value=${this.value}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${valueRenderer}
.searchKeys=${labelComboBoxKeys}
@value-changed=${this._valueChanged}
>
@@ -203,6 +251,10 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
const value = ev.detail.value;
if (value === NO_LABELS) {
return;
}
if (!value) {
this._setValue(undefined);
return;

View File

@@ -377,7 +377,7 @@ interface SelectBoxOptionImage {
}
export interface SelectOption {
value: string;
value: any;
label: string;
description?: string;
image?: string | SelectBoxOptionImage;

View File

@@ -58,7 +58,6 @@ import { fullEntitiesContext } from "../../../../data/context";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type {
Action,
DeviceAction,
NonConditionAction,
RepeatAction,
ServiceAction,
@@ -234,13 +233,6 @@ export default class HaAutomationActionRow extends LitElement {
private _renderRow() {
const type = getAutomationActionType(this.action);
const target =
type === "service" && "target" in this.action
? (this.action as ServiceAction).target
: type === "device_id" && (this.action as DeviceAction).device_id
? { device_id: (this.action as DeviceAction).device_id }
: undefined;
return html`
${type === "service" && "action" in this.action && this.action.action
? html`
@@ -262,7 +254,9 @@ export default class HaAutomationActionRow extends LitElement {
${capitalizeFirstLetter(
describeAction(this.hass, this._entityReg, this.action)
)}
${target ? this._renderTargets(target) : nothing}
${type === "service" && "target" in this.action
? this._renderTargets((this.action as ServiceAction).target)
: nothing}
</h3>
<slot name="icons" slot="icons"></slot>

View File

@@ -1504,7 +1504,14 @@ export default class HaAutomationAddFromTarget extends LitElement {
box-shadow: inset var(--ha-shadow-offset-x-lg)
calc(var(--ha-shadow-offset-y-lg) * -1) var(--ha-shadow-blur-lg)
var(--ha-shadow-spread-lg) var(--ha-color-shadow-light);
z-index: 2;
}
@media (prefers-color-scheme: dark) {
.targets-show-more {
box-shadow: inset var(--ha-shadow-offset-x-lg)
calc(var(--ha-shadow-offset-y-lg) * -1) var(--ha-shadow-blur-lg)
var(--ha-shadow-spread-lg) var(--ha-color-shadow-dark);
}
}
@media all and (max-width: 870px), all and (max-height: 500px) {

View File

@@ -76,7 +76,6 @@ import "./types/ha-automation-condition-template";
import "./types/ha-automation-condition-time";
import "./types/ha-automation-condition-trigger";
import "./types/ha-automation-condition-zone";
import type { DeviceCondition } from "../../../../data/device/device_automation";
export interface ConditionElement extends LitElement {
condition: Condition;
@@ -185,14 +184,6 @@ export default class HaAutomationConditionRow extends LitElement {
}
private _renderRow() {
const target =
"target" in (this.conditionDescriptions[this.condition.condition] || {})
? (this.condition as PlatformCondition).target
: "device_id" in this.condition &&
(this.condition as DeviceCondition).device_id
? { device_id: [(this.condition as DeviceCondition).device_id] }
: undefined;
return html`
<ha-condition-icon
slot="leading-icon"
@@ -203,7 +194,10 @@ export default class HaAutomationConditionRow extends LitElement {
${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg)
)}
${target ? this._renderTargets(target) : nothing}
${"target" in
(this.conditionDescriptions[this.condition.condition] || {})
? this._renderTargets((this.condition as PlatformCondition).target)
: nothing}
</h3>
<slot name="icons" slot="icons"></slot>

View File

@@ -56,7 +56,6 @@ import { isTrigger, subscribeTrigger } from "../../../../data/automation";
import { describeTrigger } from "../../../../data/automation_i18n";
import { validateConfig } from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context";
import type { DeviceTrigger } from "../../../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { TriggerDescriptions } from "../../../../data/trigger";
import { isTriggerList } from "../../../../data/trigger";
@@ -197,15 +196,6 @@ export default class HaAutomationTriggerRow extends LitElement {
const yamlMode = this._yamlMode || !supported;
const target =
type === "platform" &&
"target" in
this.triggerDescriptions[(this.trigger as PlatformTrigger).trigger]
? (this.trigger as PlatformTrigger).target
: type === "device" && (this.trigger as DeviceTrigger).device_id
? { device_id: (this.trigger as DeviceTrigger).device_id }
: undefined;
return html`
${type === "list"
? html`<ha-svg-icon
@@ -220,7 +210,11 @@ export default class HaAutomationTriggerRow extends LitElement {
></ha-trigger-icon>`}
<h3 slot="header">
${describeTrigger(this.trigger, this.hass, this._entityReg)}
${target ? this._renderTargets(target) : nothing}
${type === "platform" &&
"target" in
this.triggerDescriptions[(this.trigger as PlatformTrigger).trigger]
? this._renderTargets((this.trigger as PlatformTrigger).target)
: nothing}
</h3>
<slot name="icons" slot="icons"></slot>

View File

@@ -20,6 +20,7 @@ import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import { showCategoryRegistryDetailDialog } from "./show-dialog-category-registry-detail";
const ADD_NEW_ID = "___ADD_NEW___";
const NO_CATEGORIES_ID = "___NO_CATEGORIES___";
@customElement("ha-category-picker")
export class HaCategoryPicker extends SubscribeMixin(LitElement) {
@@ -100,11 +101,17 @@ export class HaCategoryPicker extends SubscribeMixin(LitElement) {
);
private _getCategories = memoizeOne(
(
categories: CategoryRegistryEntry[] | undefined
): PickerComboBoxItem[] | undefined => {
if (!categories) {
return undefined;
(categories: CategoryRegistryEntry[] | undefined): PickerComboBoxItem[] => {
if (!categories || categories.length === 0) {
return [
{
id: NO_CATEGORIES_ID,
primary: this.hass.localize(
"ui.components.category-picker.no_categories"
),
icon_path: mdiTag,
},
];
}
const items = categories.map<PickerComboBoxItem>((category) => ({
@@ -203,6 +210,10 @@ export class HaCategoryPicker extends SubscribeMixin(LitElement) {
const value = ev.detail.value;
if (value === NO_CATEGORIES_ID) {
return;
}
if (!value) {
this._setValue(undefined);
return;

View File

@@ -8,8 +8,6 @@ import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { computeEntityNameList } from "../../../common/entity/compute_entity_name_display";
import { computeRTL } from "../../../common/util/compute_rtl";
import "../../../components/ha-check-list-item";
import "../../../components/search-input";
import "../../../components/ha-dialog";
@@ -20,7 +18,6 @@ import "../../../components/ha-list";
import type { ExposeEntitySettings } from "../../../data/expose";
import { voiceAssistants } from "../../../data/expose";
import { haStyle } from "../../../resources/styles";
import { loadVirtualizer } from "../../../resources/virtualizer";
import type { HomeAssistant } from "../../../types";
import "./entity-voice-settings";
import type { ExposeEntityDialogParams } from "./show-dialog-expose-entity";
@@ -35,12 +32,6 @@ class DialogExposeEntity extends LitElement {
@state() private _selected: string[] = [];
public willUpdate(): void {
if (!this.hasUpdated) {
loadVirtualizer();
}
}
public async showDialog(params: ExposeEntityDialogParams): Promise<void> {
this._params = params;
}
@@ -152,96 +143,36 @@ class DialogExposeEntity extends LitElement {
filter?: string
) => {
const lowerFilter = filter?.toLowerCase();
return Object.values(this.hass.states).filter((entity) => {
if (
!this._params!.filterAssistants.some(
return Object.values(this.hass.states).filter(
(entity) =>
this._params!.filterAssistants.some(
(ass) => !exposedEntities[entity.entity_id]?.[ass]
)
) {
return false;
}
if (!lowerFilter) {
return true;
}
if (entity.entity_id.toLowerCase().includes(lowerFilter)) {
return true;
}
const entityName = computeStateName(entity);
if (entityName?.toLowerCase().includes(lowerFilter)) {
return true;
}
const [, deviceName, areaName] = computeEntityNameList(
entity,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
if (deviceName?.toLowerCase().includes(lowerFilter)) {
return true;
}
if (areaName?.toLowerCase().includes(lowerFilter)) {
return true;
}
return false;
});
) &&
(!lowerFilter ||
entity.entity_id.toLowerCase().includes(lowerFilter) ||
computeStateName(entity)?.toLowerCase().includes(lowerFilter))
);
}
);
private _renderItem = (entityState: HassEntity) => {
const [entityName, deviceName, areaName] = computeEntityNameList(
entityState,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const isRTL = computeRTL(this.hass);
const primary = entityName || deviceName || entityState.entity_id;
const context = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const showEntityId = this.hass.userData?.showEntityIdPicker;
return html`
<ha-check-list-item
graphic="icon"
?twoLine=${context}
?threeLine=${showEntityId}
.value=${entityState.entity_id}
.selected=${this._selected.includes(entityState.entity_id)}
@request-selected=${this._handleSelected}
>
<ha-state-icon
title=${ifDefined(entityState?.state)}
slot="graphic"
.hass=${this.hass}
.stateObj=${entityState}
></ha-state-icon>
${primary}
${context || showEntityId
? html`<span slot="secondary">
${context}
${showEntityId
? html`<br /><span class="entity-id"
>${entityState.entity_id}</span
>`
: nothing}
</span>`
: nothing}
</ha-check-list-item>
`;
};
private _renderItem = (entityState: HassEntity) => html`
<ha-check-list-item
graphic="icon"
twoLine
.value=${entityState.entity_id}
.selected=${this._selected.includes(entityState.entity_id)}
@request-selected=${this._handleSelected}
>
<ha-state-icon
title=${ifDefined(entityState?.state)}
slot="graphic"
.hass=${this.hass}
.stateObj=${entityState}
></ha-state-icon>
${computeStateName(entityState)}
<span slot="secondary">${entityState.entity_id}</span>
</ha-check-list-item>
`;
private _expose() {
this._params!.exposeEntities(this._selected);
@@ -267,7 +198,6 @@ class DialogExposeEntity extends LitElement {
width: 100%;
display: block;
box-sizing: border-box;
margin-top: var(--ha-space-2);
--text-field-suffix-padding-left: 8px;
}
.header {
@@ -280,7 +210,7 @@ class DialogExposeEntity extends LitElement {
box-sizing: border-box;
display: flex;
flex-direction: column;
margin: calc(var(--ha-space-1) * -1) 0;
margin: -4px 0;
}
.subtitle {
color: var(--secondary-text-color);
@@ -295,17 +225,9 @@ class DialogExposeEntity extends LitElement {
width: 100%;
height: 72px;
}
ha-check-list-item[threeLine] {
height: 88px;
}
ha-check-list-item .entity-id {
line-height: var(--ha-line-height-normal);
padding-left: var(--ha-space-1);
font-size: var(--ha-font-size-xs);
}
ha-check-list-item ha-state-icon {
margin-left: var(--ha-space-6);
margin-inline-start: var(--ha-space-6);
margin-left: 24px;
margin-inline-start: 24px;
margin-inline-end: initial;
}
@media all and (max-height: 800px) {
@@ -340,8 +262,8 @@ class DialogExposeEntity extends LitElement {
--text-field-suffix-padding-left: unset;
}
ha-check-list-item ha-state-icon {
margin-left: var(--ha-space-2);
margin-inline-start: var(--ha-space-2);
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
}
}

View File

@@ -167,21 +167,20 @@ export class VoiceAssistantsExpose extends LitElement {
filterable: true,
direction: "asc",
flex: 2,
template: narrow
? undefined
: (entry) => html`
${entry.name}<br />
<div class="secondary">${entry.entity_id}</div>
`,
},
area: {
title: localize("ui.panel.config.voice_assistants.expose.headers.area"),
sortable: true,
groupable: true,
filterable: true,
template: (entry) => entry.area || "—",
},
// For search & narrow
entity_id: {
title: localize(
"ui.panel.config.voice_assistants.expose.headers.entity_id"
),
sortable: true,
hidden: !narrow,
filterable: true,
defaultHidden: true,
},
domain: {
title: localize(
@@ -192,6 +191,13 @@ export class VoiceAssistantsExpose extends LitElement {
filterable: true,
groupable: true,
},
area: {
title: localize("ui.panel.config.voice_assistants.expose.headers.area"),
sortable: true,
groupable: true,
filterable: true,
template: (entry) => entry.area || "—",
},
assistants: {
title: localize(
"ui.panel.config.voice_assistants.expose.headers.assistants"
@@ -813,8 +819,8 @@ export class VoiceAssistantsExpose extends LitElement {
}
.selected-txt {
font-weight: var(--ha-font-weight-bold);
padding-left: var(--ha-space-4);
padding-inline-start: var(--ha-space-4);
padding-left: 16px;
padding-inline-start: 16px;
direction: var(--direction);
}
.table-header .selected-txt {
@@ -824,8 +830,8 @@ export class VoiceAssistantsExpose extends LitElement {
font-size: var(--ha-font-size-l);
}
.header-toolbar .header-btns {
margin-right: calc(var(--ha-space-3) * -1);
margin-inline-end: calc(var(--ha-space-3) * -1);
margin-right: -12px;
margin-inline-end: -12px;
direction: var(--direction);
}
.header-btns {
@@ -833,17 +839,17 @@ export class VoiceAssistantsExpose extends LitElement {
}
.header-btns > ha-button,
.header-btns > ha-icon-button {
margin: var(--ha-space-2);
margin: 8px;
}
ha-button-menu {
margin-left: var(--ha-space-2);
margin-inline-start: var(--ha-space-2);
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
}
.clear {
color: var(--primary-color);
padding-left: var(--ha-space-2);
padding-inline-start: var(--ha-space-2);
padding-left: 8px;
padding-inline-start: 8px;
text-transform: uppercase;
direction: var(--direction);
}

View File

@@ -2,6 +2,8 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
import type { HomeAssistant } from "../../../../types";
import type { ClockCardConfig } from "../types";
@@ -26,6 +28,11 @@ function romanize12HourClock(num: number) {
return numerals[num];
}
const INTERVAL = 1000;
const QUARTER_TICKS = Array.from({ length: 4 }, (_, i) => i);
const HOUR_TICKS = Array.from({ length: 12 }, (_, i) => i);
const MINUTE_TICKS = Array.from({ length: 60 }, (_, i) => i);
@customElement("hui-clock-card-analog")
export class HuiClockCardAnalog extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@@ -40,27 +47,32 @@ export class HuiClockCardAnalog extends LitElement {
@state() private _secondOffsetSec?: number;
private _initDate() {
if (!this.config || !this.hass) {
return;
@state() private _year = "";
@state() private _month = "";
@state() private _day = "";
private _tickInterval?: undefined | number;
private _currentDate = new Date();
public connectedCallback() {
super.connectedCallback();
document.addEventListener("visibilitychange", this._handleVisibilityChange);
this._computeDateTime();
if (this.config?.date && this.config.date !== "none") {
this._startTick();
}
}
let locale = this.hass.locale;
if (this.config.time_format) {
locale = { ...locale, time_format: this.config.time_format };
}
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h12",
timeZone:
this.config.time_zone ||
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone),
});
this._computeOffsets();
public disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener(
"visibilitychange",
this._handleVisibilityChange
);
this._stopTick();
}
protected updated(changedProps: PropertyValues) {
@@ -72,30 +84,78 @@ export class HuiClockCardAnalog extends LitElement {
}
}
public connectedCallback() {
super.connectedCallback();
document.addEventListener("visibilitychange", this._handleVisibilityChange);
this._computeOffsets();
}
public disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener(
"visibilitychange",
this._handleVisibilityChange
);
}
private _handleVisibilityChange = () => {
if (!document.hidden) {
this._computeOffsets();
this._computeDateTime();
}
};
private _computeOffsets() {
private _initDate() {
if (!this.config || !this.hass) {
return;
}
let locale = this.hass.locale;
if (this.config.time_format) {
locale = { ...locale, time_format: this.config.time_format };
}
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
...(this.config.date && this.config.date !== "none"
? this.config.date === "day"
? {
day: "numeric",
}
: this.config.date === "day-month"
? {
month: "short",
day: "numeric",
}
: this.config.date === "day-month-long"
? {
month: "long",
day: "numeric",
}
: {
year: "numeric",
month: this.config.date === "long" ? "long" : "short",
day: "numeric",
}
: {}),
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h12",
timeZone:
this.config.time_zone ||
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone),
});
this._computeDateTime();
}
private _startTick() {
this._tick();
this._tickInterval = window.setInterval(() => this._tick(), INTERVAL);
}
private _stopTick() {
if (this._tickInterval) {
clearInterval(this._tickInterval);
this._tickInterval = undefined;
}
}
private _updateDate() {
this._currentDate = new Date();
}
private _computeDateTime() {
if (!this._dateTimeFormat) return;
const parts = this._dateTimeFormat.formatToParts();
this._updateDate();
const parts = this._dateTimeFormat.formatToParts(this._currentDate);
const hourStr = parts.find((p) => p.type === "hour")?.value;
const minuteStr = parts.find((p) => p.type === "minute")?.value;
const secondStr = parts.find((p) => p.type === "second")?.value;
@@ -103,7 +163,7 @@ export class HuiClockCardAnalog extends LitElement {
const hour = hourStr ? parseInt(hourStr, 10) : 0;
const minute = minuteStr ? parseInt(minuteStr, 10) : 0;
const second = secondStr ? parseInt(secondStr, 10) : 0;
const ms = new Date().getMilliseconds();
const ms = this._currentDate.getMilliseconds();
const secondsWithMs = second + ms / 1000;
const hour12 = hour % 12;
@@ -111,18 +171,38 @@ export class HuiClockCardAnalog extends LitElement {
this._secondOffsetSec = secondsWithMs;
this._minuteOffsetSec = minute * 60 + secondsWithMs;
this._hourOffsetSec = hour12 * 3600 + minute * 60 + secondsWithMs;
// Also update date parts if date is shown
if (this.config?.date && this.config.date !== "none") {
this._year = parts.find((p) => p.type === "year")?.value ?? "";
this._month = parts.find((p) => p.type === "month")?.value ?? "";
this._day = parts.find((p) => p.type === "day")?.value ?? "";
}
}
private _tick() {
this._computeDateTime();
}
private _computeClock = memoizeOne((config: ClockCardConfig) => {
const faceParts = config.face_style?.split("_");
const isLongDate =
config.date === "day-month-long" || config.date === "long";
return {
sizeClass: config.clock_size ? `size-${config.clock_size}` : "",
isNumbers: faceParts?.includes("numbers") ?? false,
isRoman: faceParts?.includes("roman") ?? false,
isUpright: faceParts?.includes("upright") ?? false,
isLongDate,
};
});
render() {
if (!this.config) return nothing;
const sizeClass = this.config.clock_size
? `size-${this.config.clock_size}`
: "";
const isNumbers = this.config?.face_style?.startsWith("numbers");
const isRoman = this.config?.face_style?.startsWith("roman");
const isUpright = this.config?.face_style?.endsWith("upright");
const { sizeClass, isNumbers, isRoman, isUpright, isLongDate } =
this._computeClock(this.config);
const indicator = (number?: number) => html`
<div
@@ -163,14 +243,14 @@ export class HuiClockCardAnalog extends LitElement {
})}
>
${this.config.ticks === "quarter"
? Array.from({ length: 4 }, (_, i) => i).map(
? QUARTER_TICKS.map(
(i) =>
// 4 ticks (12, 3, 6, 9) at 0°, 90°, 180°, 270°
html`
<div
aria-hidden="true"
class="tick hour"
style=${`--tick-rotation: ${i * 90}deg;`}
style=${styleMap({ "--tick-rotation": `${i * 90}deg` })}
>
${indicator([12, 3, 6, 9][i])}
</div>
@@ -178,28 +258,30 @@ export class HuiClockCardAnalog extends LitElement {
)
: !this.config.ticks || // Default to hour ticks
this.config.ticks === "hour"
? Array.from({ length: 12 }, (_, i) => i).map(
? HOUR_TICKS.map(
(i) =>
// 12 ticks (1-12)
html`
<div
aria-hidden="true"
class="tick hour"
style=${`--tick-rotation: ${i * 30}deg;`}
style=${styleMap({ "--tick-rotation": `${i * 30}deg` })}
>
${indicator(((i + 11) % 12) + 1)}
</div>
`
)
: this.config.ticks === "minute"
? Array.from({ length: 60 }, (_, i) => i).map(
? MINUTE_TICKS.map(
(i) =>
// 60 ticks (1-60)
html`
<div
aria-hidden="true"
class="tick ${i % 5 === 0 ? "hour" : "minute"}"
style=${`--tick-rotation: ${i * 6}deg;`}
style=${styleMap({
"--tick-rotation": `${i * 6}deg`,
})}
>
${i % 5 === 0
? indicator(((i / 5 + 11) % 12) + 1)
@@ -208,14 +290,33 @@ export class HuiClockCardAnalog extends LitElement {
`
)
: nothing}
${this.config?.date && this.config.date !== "none"
? html`<div
class=${classMap({
"date-parts": true,
[sizeClass]: true,
"long-date": isLongDate,
})}
>
<span class="date-part day-month"
>${this._day} ${this._month}</span
>
<span class="date-part year">${this._year}</span>
</div>`
: nothing}
<div class="center-dot"></div>
<div
class="hand hour"
style=${`animation-delay: -${this._hourOffsetSec ?? 0}s;`}
style=${styleMap({
"animation-delay": `-${this._hourOffsetSec ?? 0}s`,
})}
></div>
<div
class="hand minute"
style=${`animation-delay: -${this._minuteOffsetSec ?? 0}s;`}
style=${styleMap({
"animation-delay": `-${this._minuteOffsetSec ?? 0}s`,
})}
></div>
${this.config.show_seconds
? html`<div
@@ -224,11 +325,13 @@ export class HuiClockCardAnalog extends LitElement {
second: true,
step: this.config.seconds_motion === "tick",
})}
style=${`animation-delay: -${
(this.config.seconds_motion === "tick"
? Math.floor(this._secondOffsetSec ?? 0)
: (this._secondOffsetSec ?? 0)) as number
}s;`}
style=${styleMap({
"animation-delay": `-${
(this.config.seconds_motion === "tick"
? Math.floor(this._secondOffsetSec ?? 0)
: (this._secondOffsetSec ?? 0)) as number
}s`,
})}
></div>`
: nothing}
</div>
@@ -270,6 +373,14 @@ export class HuiClockCardAnalog extends LitElement {
box-sizing: border-box;
}
/* Modern browsers: Use container queries for responsive font sizing */
@supports (container-type: inline-size) {
.dial {
container-type: inline-size;
container-name: clock;
}
}
.dial-border {
border: 2px solid var(--divider-color);
border-radius: var(--ha-border-radius-circle);
@@ -407,6 +518,78 @@ export class HuiClockCardAnalog extends LitElement {
transform: translate(-50%, 0) rotate(360deg);
}
}
.date-parts {
position: absolute;
top: 68%;
left: 50%;
transform: translate(-50%, -50%);
display: grid;
align-items: center;
grid-template-areas:
"day-month"
"year";
direction: ltr;
color: var(--primary-text-color);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
text-align: center;
opacity: 0.8;
max-width: 87%;
overflow: hidden;
white-space: nowrap;
}
/* Modern browsers: Use container queries for responsive font sizing */
@supports (container-type: inline-size) {
/* Small clock with long date: reduce to xs */
@container clock (max-width: 139px) {
.date-parts.long-date {
font-size: var(--ha-font-size-xs);
}
}
/* Medium clock: scale up */
@container clock (min-width: 140px) {
.date-parts {
font-size: var(--ha-font-size-l);
}
}
/* Large clock: scale up more */
@container clock (min-width: 200px) {
.date-parts {
font-size: var(--ha-font-size-xl);
}
}
}
/* Legacy browsers: Use existing size classes */
@supports not (container-type: inline-size) {
/* Small clock (no size class) with long date */
.date-parts.long-date:not(.size-medium):not(.size-large) {
font-size: var(--ha-font-size-xs);
}
.date-parts.size-medium {
font-size: var(--ha-font-size-l);
}
.date-parts.size-large {
font-size: var(--ha-font-size-xl);
}
}
.date-part.day-month {
grid-area: day-month;
overflow: hidden;
}
.date-part.year {
grid-area: year;
overflow: hidden;
}
`;
}

View File

@@ -24,6 +24,8 @@ export class HuiClockCardDigital extends LitElement {
@state() private _timeAmPm?: string;
@state() private _date?: string;
private _tickInterval?: undefined | number;
private _initDate() {
@@ -39,6 +41,27 @@ export class HuiClockCardDigital extends LitElement {
const h12 = useAmPm(locale);
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
...(this.config.date && this.config.date !== "none"
? this.config.date === "day"
? {
day: "numeric",
}
: this.config.date === "day-month"
? {
month: "short",
day: "numeric",
}
: this.config.date === "day-month-long"
? {
month: "long",
day: "numeric",
}
: {
year: "numeric",
month: this.config.date === "long" ? "long" : "short",
day: "numeric",
}
: {}),
hour: h12 ? "numeric" : "2-digit",
minute: "2-digit",
second: "2-digit",
@@ -93,6 +116,16 @@ export class HuiClockCardDigital extends LitElement {
? parts.find((part) => part.type === "second")?.value
: undefined;
this._timeAmPm = parts.find((part) => part.type === "dayPeriod")?.value;
this._date = this.config?.date
? [
parts.find((part) => part.type === "day")?.value,
parts.find((part) => part.type === "month")?.value,
parts.find((part) => part.type === "year")?.value,
]
.filter(Boolean)
.join(" ")
: undefined;
}
render() {
@@ -113,6 +146,9 @@ export class HuiClockCardDigital extends LitElement {
? html`<div class="time-part am-pm">${this._timeAmPm}</div>`
: nothing}
</div>
${this.config.date && this.config.date !== "none"
? html`<div class="date ${sizeClass}">${this._date}</div>`
: nothing}
`;
}
@@ -188,6 +224,20 @@ export class HuiClockCardDigital extends LitElement {
content: ":";
margin: 0 2px;
}
.date {
text-align: center;
opacity: 0.8;
font-size: var(--ha-font-size-s);
}
.date.size-medium {
font-size: var(--ha-font-size-l);
}
.date.size-large {
font-size: var(--ha-font-size-2xl);
}
`;
}

View File

@@ -73,18 +73,13 @@ export class HuiCard extends ConditionalListenerMixin<LovelaceCardConfig>(
};
// If the element has fixed rows or columns, we use the values from the element
// unless the user has already configured their own
if (elementOptions.fixed_rows) {
if (configOptions.rows === undefined) {
mergedConfig.rows = elementOptions.rows;
}
mergedConfig.rows = elementOptions.rows;
delete mergedConfig.min_rows;
delete mergedConfig.max_rows;
}
if (elementOptions.fixed_columns) {
if (configOptions.columns === undefined) {
mergedConfig.columns = elementOptions.columns;
}
mergedConfig.columns = elementOptions.columns;
delete mergedConfig.min_columns;
delete mergedConfig.max_columns;
}

View File

@@ -11,10 +11,7 @@ import { findEntities } from "../common/find-entities";
import type { LovelaceElement, LovelaceElementConfig } from "../elements/types";
import type { LovelaceCard, LovelaceCardEditor } from "../types";
import { createStyledHuiElement } from "./picture-elements/create-styled-hui-element";
import {
PREVIEW_CLICK_CALLBACK,
type PictureElementsCardConfig,
} from "./types";
import type { PictureElementsCardConfig } from "./types";
import type { PersonEntity } from "../../../data/person";
@customElement("hui-picture-elements-card")
@@ -169,7 +166,6 @@ class HuiPictureElementsCard extends LitElement implements LovelaceCard {
.aspectRatio=${this._config.aspect_ratio}
.darkModeFilter=${this._config.dark_mode_filter}
.darkModeImage=${darkModeImage}
@click=${this._handleImageClick}
></hui-image>
${this._elements}
</div>
@@ -225,19 +221,6 @@ class HuiPictureElementsCard extends LitElement implements LovelaceCard {
curCardEl === elToReplace ? newCardEl : curCardEl
);
}
private _handleImageClick(ev: MouseEvent): void {
if (!this.preview || !this._config?.[PREVIEW_CLICK_CALLBACK]) {
return;
}
const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect();
const x = ((ev.clientX - rect.left) / rect.width) * 100;
const y = ((ev.clientY - rect.top) / rect.height) * 100;
// only the edited card has this callback
this._config[PREVIEW_CLICK_CALLBACK](x, y);
}
}
declare global {

View File

@@ -421,6 +421,7 @@ export interface ClockCardConfig extends LovelaceCardConfig {
time_format?: TimeFormat;
time_zone?: string;
no_background?: boolean;
date?: "none" | "short" | "long" | "day" | "day-month" | "day-month-long";
// Analog clock options
border?: boolean;
ticks?: "none" | "quarter" | "hour" | "minute";
@@ -487,10 +488,6 @@ export interface PictureCardConfig extends LovelaceCardConfig {
alt_text?: string;
}
// Symbol for preview click callback - preserved through spreads, not serialized
// This allows the editor to attach a callback that only exists on the edited card's config
export const PREVIEW_CLICK_CALLBACK = Symbol("previewClickCallback");
export interface PictureElementsCardConfig extends LovelaceCardConfig {
title?: string;
image?: string | MediaSelectorValue;
@@ -505,7 +502,6 @@ export interface PictureElementsCardConfig extends LovelaceCardConfig {
theme?: string;
dark_mode_image?: string | MediaSelectorValue;
dark_mode_filter?: string;
[PREVIEW_CLICK_CALLBACK]?: (x: number, y: number) => void;
}
export interface PictureEntityCardConfig extends LovelaceCardConfig {

View File

@@ -39,6 +39,19 @@ const cardConfigStruct = assign(
time_zone: optional(enums(Object.keys(timezones))),
show_seconds: optional(boolean()),
no_background: optional(boolean()),
date: optional(
defaulted(
union([
literal("none"),
literal("short"),
literal("long"),
literal("day"),
literal("day-month"),
literal("day-month-long"),
]),
literal("none")
)
),
// Analog clock options
border: optional(defaulted(boolean(), false)),
ticks: optional(
@@ -93,7 +106,7 @@ export class HuiClockCardEditor
name: "clock_style",
selector: {
select: {
mode: "dropdown",
mode: "box",
options: ["digital", "analog"].map((value) => ({
value,
label: localize(
@@ -119,6 +132,27 @@ export class HuiClockCardEditor
},
{ name: "show_seconds", selector: { boolean: {} } },
{ name: "no_background", selector: { boolean: {} } },
{
name: "date",
selector: {
select: {
mode: "dropdown",
options: [
"none",
"short",
"long",
"day",
"day-month",
"day-month-long",
].map((value) => ({
value,
label: localize(
`ui.panel.lovelace.editor.card.clock.dates.${value}`
),
})),
},
},
},
...(clockStyle === "digital"
? ([
{
@@ -260,13 +294,14 @@ export class HuiClockCardEditor
] as const satisfies readonly HaFormSchema[]
);
private _data = memoizeOne((config) => ({
private _data = memoizeOne((config: ClockCardConfig) => ({
clock_style: "digital",
clock_size: "small",
time_zone: "auto",
time_format: "auto",
show_seconds: false,
no_background: false,
date: "none",
// Analog clock options
border: false,
ticks: "hour",
@@ -290,8 +325,9 @@ export class HuiClockCardEditor
.data=${this._data(this._config)}
.schema=${this._schema(
this.hass.localize,
this._data(this._config).clock_style,
this._data(this._config).ticks,
this._data(this._config)
.clock_style as ClockCardConfig["clock_style"],
this._data(this._config).ticks as ClockCardConfig["ticks"],
this._data(this._config).show_seconds
)}
.computeLabel=${this._computeLabelCallback}
@@ -367,6 +403,10 @@ export class HuiClockCardEditor
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.no_background`
);
case "date":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.date.label`
);
case "border":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.border.label`
@@ -392,6 +432,10 @@ export class HuiClockCardEditor
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "date":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.date.description`
);
case "border":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.border.description`

View File

@@ -15,16 +15,12 @@ import {
} from "superstruct";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-alert";
import "../../../../components/ha-card";
import "../../../../components/ha-form/ha-form";
import "../../../../components/ha-icon";
import "../../../../components/ha-switch";
import type { HomeAssistant } from "../../../../types";
import {
PREVIEW_CLICK_CALLBACK,
type PictureElementsCardConfig,
} from "../../cards/types";
import type { PictureElementsCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
import "../hui-sub-element-editor";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
@@ -32,6 +28,7 @@ import type { EditDetailElementEvent, SubElementEditorConfig } from "../types";
import { configElementStyle } from "./config-elements-style";
import "../hui-picture-elements-card-row-editor";
import type { LovelaceElementConfig } from "../../elements/types";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { LocalizeFunc } from "../../../../common/translations/localize";
const genericElementConfigStruct = type({
@@ -69,44 +66,6 @@ export class HuiPictureElementsCardEditor
this._config = config;
}
private _onPreviewClick = (x: number, y: number): void => {
if (this._subElementEditorConfig?.type === "element") {
this._handlePositionClick(x, y);
}
};
private _handlePositionClick(x: number, y: number): void {
if (
!this._subElementEditorConfig?.elementConfig ||
this._subElementEditorConfig.type !== "element" ||
this._subElementEditorConfig.elementConfig.type === "conditional"
) {
return;
}
const elementConfig = this._subElementEditorConfig
.elementConfig as LovelaceElementConfig;
const currentPosition = (elementConfig.style as Record<string, string>)
?.position;
if (currentPosition && currentPosition !== "absolute") {
return;
}
const newElement = {
...elementConfig,
style: {
...((elementConfig.style as Record<string, string>) || {}),
left: `${Math.round(x)}%`,
top: `${Math.round(y)}%`,
},
};
const updateEvent = new CustomEvent("config-changed", {
detail: { config: newElement },
});
this._handleSubElementChanged(updateEvent);
}
private _schema = memoizeOne(
(localize: LocalizeFunc) =>
[
@@ -179,16 +138,6 @@ export class HuiPictureElementsCardEditor
if (this._subElementEditorConfig) {
return html`
${this._subElementEditorConfig.type === "element" &&
this._subElementEditorConfig.elementConfig?.type !== "conditional"
? html`
<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.lovelace.editor.card.picture-elements.position_hint"
)}
</ha-alert>
`
: nothing}
<hui-sub-element-editor
.hass=${this.hass}
.config=${this._subElementEditorConfig}
@@ -232,7 +181,6 @@ export class HuiPictureElementsCardEditor
return;
}
// no need to attach the preview click callback here, no element is being edited
fireEvent(this, "config-changed", { config: ev.detail.value });
}
@@ -243,8 +191,7 @@ export class HuiPictureElementsCardEditor
const config = {
...this._config,
elements: ev.detail.elements as LovelaceElementConfig[],
[PREVIEW_CLICK_CALLBACK]: this._onPreviewClick,
} as PictureElementsCardConfig;
} as LovelaceCardConfig;
fireEvent(this, "config-changed", { config });
@@ -285,12 +232,7 @@ export class HuiPictureElementsCardEditor
elementConfig: value,
};
fireEvent(this, "config-changed", {
config: {
...this._config,
[PREVIEW_CLICK_CALLBACK]: this._onPreviewClick,
},
});
fireEvent(this, "config-changed", { config: this._config });
}
private _editDetailElement(ev: HASSDomEvent<EditDetailElementEvent>): void {

View File

@@ -10,9 +10,7 @@ export const getElementStubConfig = async (
): Promise<LovelaceElementConfig> => {
let elementConfig: LovelaceElementConfig = { type };
if (type === "conditional") {
elementConfig = { type, conditions: [], elements: [] };
} else {
if (type !== "conditional") {
elementConfig.style = { left: "50%", top: "50%" };
}

View File

@@ -89,11 +89,7 @@ export abstract class HuiElementEditor<
}
public set value(config: T | undefined) {
// Compare symbols to detect callback changes (e.g., preview click handlers)
if (
this._config &&
deepEqual(config, this._config, { compareSymbols: true })
) {
if (this._config && deepEqual(config, this._config)) {
return;
}
this._config = config;

View File

@@ -15,11 +15,7 @@ import { tags } from "@lezer/highlight";
export { autocompletion } from "@codemirror/autocomplete";
export { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
export { highlightingFor, foldGutter } from "@codemirror/language";
export {
highlightSelectionMatches,
search,
searchKeymap,
} from "@codemirror/search";
export { highlightSelectionMatches, searchKeymap } from "@codemirror/search";
export { EditorState } from "@codemirror/state";
export {
crosshairCursor,

View File

@@ -15,27 +15,27 @@ export const semanticColorStyles = css`
--ha-color-text-disabled: var(--ha-color-neutral-60);
--ha-color-text-link: var(--ha-color-primary-40);
/* border primary */
--ha-color-border-primary-quiet: var(--ha-color-primary-90);
--ha-color-border-primary-quiet: var(--ha-color-primary-80);
--ha-color-border-primary-normal: var(--ha-color-primary-70);
--ha-color-border-primary-loud: var(--ha-color-primary-40);
/* border neutral */
--ha-color-border-neutral-quiet: var(--ha-color-neutral-90);
--ha-color-border-neutral-quiet: var(--ha-color-neutral-80);
--ha-color-border-neutral-normal: var(--ha-color-neutral-60);
--ha-color-border-neutral-loud: var(--ha-color-neutral-40);
/* border danger */
--ha-color-border-danger-quiet: var(--ha-color-red-90);
--ha-color-border-danger-quiet: var(--ha-color-red-80);
--ha-color-border-danger-normal: var(--ha-color-red-70);
--ha-color-border-danger-loud: var(--ha-color-red-40);
/* border warning */
--ha-color-border-warning-quiet: var(--ha-color-orange-90);
--ha-color-border-warning-quiet: var(--ha-color-orange-80);
--ha-color-border-warning-normal: var(--ha-color-orange-70);
--ha-color-border-warning-loud: var(--ha-color-orange-40);
/* border success */
--ha-color-border-success-quiet: var(--ha-color-green-90);
--ha-color-border-success-quiet: var(--ha-color-green-80);
--ha-color-border-success-normal: var(--ha-color-green-70);
--ha-color-border-success-loud: var(--ha-color-green-40);

View File

@@ -8241,6 +8241,18 @@
"large": "Large"
},
"show_seconds": "Display seconds",
"date": {
"label": "Date",
"description": "Whether to show the date on the clock. Can also be a custom date format."
},
"dates": {
"none": "No date",
"short": "Short",
"long": "Long",
"day": "Day only",
"day-month": "Day and month",
"day-month-long": "Day and month (long)"
},
"time_format": "[%key:ui::panel::profile::time_format::dropdown_label%]",
"time_formats": {
"auto": "Use user settings",
@@ -8326,7 +8338,6 @@
"dark_mode_image": "Dark mode image path",
"state_filter": "State filter",
"dark_mode_filter": "Dark mode state filter",
"position_hint": "Click on the image preview to position this element",
"element_types": {
"state-badge": "State badge",
"state-icon": "State icon",