mirror of
https://github.com/home-assistant/frontend.git
synced 2026-01-10 01:07:32 +00:00
Compare commits
21 Commits
padding-co
...
clock-date
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46d48bf1af | ||
|
|
572dea2b0c | ||
|
|
e34db36098 | ||
|
|
3a6109e147 | ||
|
|
ce1e89e716 | ||
|
|
75f9baf6ef | ||
|
|
d4138dced9 | ||
|
|
3dc34cb1cc | ||
|
|
858eeb7960 | ||
|
|
f22f6b74db | ||
|
|
913c4ae24e | ||
|
|
4b7b5fa21a | ||
|
|
bf6887541b | ||
|
|
26da9f3a37 | ||
|
|
d48520efdf | ||
|
|
d462356122 | ||
|
|
9a5cdb0a99 | ||
|
|
eaf012d5ff | ||
|
|
19934dad72 | ||
|
|
6194f73442 | ||
|
|
dbc880fe35 |
4
.github/copilot-instructions.md
vendored
4
.github/copilot-instructions.md
vendored
@@ -22,11 +22,13 @@ You are an assistant helping with development of the Home Assistant frontend. Th
|
||||
```bash
|
||||
yarn lint # ESLint + Prettier + TypeScript + Lit
|
||||
yarn format # Auto-fix ESLint + Prettier
|
||||
yarn lint:types # TypeScript compiler
|
||||
yarn lint:types # TypeScript compiler (run WITHOUT file arguments)
|
||||
yarn test # Vitest
|
||||
script/develop # Development server
|
||||
```
|
||||
|
||||
> **WARNING:** Never run `tsc` or `yarn lint:types` with file arguments (e.g., `yarn lint:types src/file.ts`). When `tsc` receives file arguments, it ignores `tsconfig.json` and emits `.js` files into `src/`, polluting the codebase. Always run `yarn lint:types` without arguments. For individual file type checking, rely on IDE diagnostics. If `.js` files are accidentally generated, clean up with `git clean -fd src/`.
|
||||
|
||||
### Component Prefixes
|
||||
|
||||
- `ha-` - Home Assistant components
|
||||
|
||||
@@ -18,10 +18,7 @@ import "../ha-combo-box-item";
|
||||
import "../ha-generic-picker";
|
||||
import type { HaGenericPicker } from "../ha-generic-picker";
|
||||
import "../ha-input-helper-text";
|
||||
import {
|
||||
NO_ITEMS_AVAILABLE_ID,
|
||||
type PickerComboBoxItem,
|
||||
} from "../ha-picker-combo-box";
|
||||
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
|
||||
import "../ha-sortable";
|
||||
|
||||
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
|
||||
@@ -184,18 +181,17 @@ export class HaEntityNamePicker extends LitElement {
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required && !value.length}
|
||||
.getItems=${this._getFilteredItems}
|
||||
.getAdditionalItems=${this._getAdditionalItems}
|
||||
.rowRenderer=${rowRenderer}
|
||||
.searchFn=${this._searchFn}
|
||||
.notFoundLabel=${this.hass.localize(
|
||||
"ui.components.entity.entity-name-picker.no_match"
|
||||
)}
|
||||
.value=${this._getPickerValue()}
|
||||
allow-custom-value
|
||||
.customValueLabel=${this.hass.localize(
|
||||
"ui.components.entity.entity-name-picker.custom_name"
|
||||
)}
|
||||
@value-changed=${this._pickerValueChanged}
|
||||
.searchFn=${this._searchFn}
|
||||
.searchLabel=${this.hass.localize(
|
||||
"ui.components.entity.entity-name-picker.search"
|
||||
)}
|
||||
>
|
||||
<div slot="field" class="container">
|
||||
<ha-sortable
|
||||
@@ -316,10 +312,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _getFilteredItems = (
|
||||
searchString?: string,
|
||||
_section?: string
|
||||
): PickerComboBoxItem[] => {
|
||||
private _getFilteredItems = (): PickerComboBoxItem[] => {
|
||||
const items = this._getItems(this.entityId);
|
||||
const currentItem =
|
||||
this._editIndex != null ? this._items[this._editIndex] : undefined;
|
||||
@@ -336,49 +329,27 @@ export class HaEntityNamePicker extends LitElement {
|
||||
);
|
||||
|
||||
// When editing an existing text item, include it in the base items
|
||||
if (currentItem?.type === "text" && currentItem.text && !searchString) {
|
||||
if (currentItem?.type === "text" && currentItem.text) {
|
||||
filteredItems.push(this._customNameOption(currentItem.text));
|
||||
}
|
||||
|
||||
return filteredItems;
|
||||
};
|
||||
|
||||
private _getAdditionalItems = (
|
||||
searchString?: string
|
||||
private _searchFn = (
|
||||
searchString: string,
|
||||
filteredItems: PickerComboBoxItem[]
|
||||
): PickerComboBoxItem[] => {
|
||||
if (!searchString) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const currentItem =
|
||||
this._editIndex != null ? this._items[this._editIndex] : undefined;
|
||||
const currentId =
|
||||
currentItem?.type === "text" && currentItem.text
|
||||
? this._customNameOption(currentItem.text).id
|
||||
: undefined;
|
||||
|
||||
// Don't add if it's the same as the current item being edited
|
||||
if (
|
||||
currentItem?.type === "text" &&
|
||||
currentItem.text &&
|
||||
currentItem.text === searchString
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Always return custom name option when there's a search string
|
||||
// This prevents "No matching items found" from showing
|
||||
return [this._customNameOption(searchString)];
|
||||
};
|
||||
|
||||
private _searchFn = (
|
||||
search: string,
|
||||
filteredItems: PickerComboBoxItem[],
|
||||
_allItems: PickerComboBoxItem[]
|
||||
): PickerComboBoxItem[] => {
|
||||
// Remove NO_ITEMS_AVAILABLE_ID if we have additional items (custom name option)
|
||||
// This prevents "No matching items found" from showing when custom values are allowed
|
||||
const hasAdditionalItems = this._getAdditionalItems(search).length > 0;
|
||||
if (hasAdditionalItems) {
|
||||
return filteredItems.filter(
|
||||
(item) => typeof item !== "string" || item !== NO_ITEMS_AVAILABLE_ID
|
||||
);
|
||||
// Remove custom name option if search string is present to avoid duplicates
|
||||
if (searchString && currentId) {
|
||||
return filteredItems.filter((item) => item.id !== currentId);
|
||||
}
|
||||
return filteredItems;
|
||||
};
|
||||
|
||||
@@ -19,7 +19,10 @@ import "../ha-combo-box-item";
|
||||
import "../ha-generic-picker";
|
||||
import type { HaGenericPicker } from "../ha-generic-picker";
|
||||
import "../ha-input-helper-text";
|
||||
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
|
||||
import {
|
||||
NO_ITEMS_AVAILABLE_ID,
|
||||
type PickerComboBoxItem,
|
||||
} from "../ha-picker-combo-box";
|
||||
import "../ha-sortable";
|
||||
|
||||
const HIDDEN_ATTRIBUTES = [
|
||||
@@ -199,11 +202,7 @@ export class HaStateContentPicker extends LitElement {
|
||||
.value=${this._getPickerValue()}
|
||||
.getItems=${this._getFilteredItems}
|
||||
.getAdditionalItems=${this._getAdditionalItems}
|
||||
.notFoundLabel=${this.hass.localize("ui.components.combo-box.no_match")}
|
||||
allow-custom-value
|
||||
.customValueLabel=${this.hass.localize(
|
||||
"ui.components.entity.entity-state-content-picker.custom_state"
|
||||
)}
|
||||
.searchFn=${this._searchFn}
|
||||
@value-changed=${this._pickerValueChanged}
|
||||
>
|
||||
<div slot="field" class="container">
|
||||
@@ -328,7 +327,7 @@ export class HaStateContentPicker extends LitElement {
|
||||
(text: string): PickerComboBoxItem => ({
|
||||
id: text,
|
||||
primary: this.hass.localize(
|
||||
"ui.components.entity.entity-state-content-picker.custom_state"
|
||||
"ui.components.entity.entity-state-content-picker.custom_attribute"
|
||||
),
|
||||
secondary: `"${text}"`,
|
||||
search_labels: {
|
||||
@@ -340,10 +339,7 @@ export class HaStateContentPicker extends LitElement {
|
||||
})
|
||||
);
|
||||
|
||||
private _getFilteredItems = (
|
||||
searchString?: string,
|
||||
_section?: string
|
||||
): PickerComboBoxItem[] => {
|
||||
private _getFilteredItems = (): PickerComboBoxItem[] => {
|
||||
const stateObj = this.entityId
|
||||
? this.hass.states[this.entityId]
|
||||
: undefined;
|
||||
@@ -358,11 +354,7 @@ export class HaStateContentPicker extends LitElement {
|
||||
);
|
||||
|
||||
// When editing an existing custom value, include it in the base items
|
||||
if (
|
||||
currentValue &&
|
||||
!items.find((item) => item.id === currentValue) &&
|
||||
!searchString
|
||||
) {
|
||||
if (currentValue && !items.find((item) => item.id === currentValue)) {
|
||||
filteredItems.push(this._customValueOption(currentValue));
|
||||
}
|
||||
|
||||
@@ -372,33 +364,34 @@ export class HaStateContentPicker extends LitElement {
|
||||
private _getAdditionalItems = (
|
||||
searchString?: string
|
||||
): PickerComboBoxItem[] => {
|
||||
if (!searchString) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const currentValue =
|
||||
this._editIndex != null ? this._value[this._editIndex] : undefined;
|
||||
|
||||
// Don't add if it's the same as the current item being edited
|
||||
if (currentValue && currentValue === searchString) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check if the search string matches an existing item
|
||||
const stateObj = this.entityId
|
||||
? this.hass.states[this.entityId]
|
||||
: undefined;
|
||||
const items = this._getItems(this.entityId, stateObj, this.allowName);
|
||||
const existingItem = items.find((item) => item.id === searchString);
|
||||
|
||||
// Only return custom value option if it doesn't match an existing item
|
||||
if (!existingItem) {
|
||||
// If the search string does not match with the id of any of the items,
|
||||
// offer to add it as a custom attribute
|
||||
const existingItem = items.find((item) => item.id === searchString);
|
||||
if (searchString && !existingItem) {
|
||||
return [this._customValueOption(searchString)];
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
private _searchFn = (
|
||||
search: string,
|
||||
filteredItems: PickerComboBoxItem[],
|
||||
_allItems: PickerComboBoxItem[]
|
||||
): PickerComboBoxItem[] => {
|
||||
if (!search) {
|
||||
return filteredItems;
|
||||
}
|
||||
|
||||
// Always exclude NO_ITEMS_AVAILABLE_ID (since custom values are allowed) and currentValue (the custom value being edited)
|
||||
return filteredItems.filter((item) => item.id !== NO_ITEMS_AVAILABLE_ID);
|
||||
};
|
||||
|
||||
private async _moveItem(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const { oldIndex, newIndex } = ev.detail;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getStates } from "../../common/entity/get_states";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import "../ha-generic-picker";
|
||||
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
|
||||
import type { PickerValueRenderer } from "../ha-picker-field";
|
||||
|
||||
@customElement("ha-entity-state-picker")
|
||||
export class HaEntityStatePicker extends LitElement {
|
||||
@@ -108,6 +109,12 @@ export class HaEntityStatePicker extends LitElement {
|
||||
this.extraOptions
|
||||
);
|
||||
|
||||
private _valueRenderer: PickerValueRenderer = (value: string) => {
|
||||
const items = this._getFilteredItems();
|
||||
const item = items.find((option) => option.id === value);
|
||||
return html`<span slot="headline">${item?.primary ?? value}</span>`;
|
||||
};
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
@@ -125,6 +132,7 @@ export class HaEntityStatePicker extends LitElement {
|
||||
.helper=${this.helper}
|
||||
.value=${this.value}
|
||||
.getItems=${this._getFilteredItems}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
.notFoundLabel=${this.hass.localize("ui.components.combo-box.no_match")}
|
||||
.customValueLabel=${this.hass.localize(
|
||||
"ui.components.entity.entity-state-picker.add_custom_state"
|
||||
|
||||
@@ -124,9 +124,6 @@ export class HaIconPicker extends LitElement {
|
||||
.label=${this.label}
|
||||
.value=${this._value}
|
||||
.searchFn=${this._filterIcons}
|
||||
.notFoundLabel=${this.hass?.localize(
|
||||
"ui.components.icon-picker.no_match"
|
||||
)}
|
||||
popover-placement="bottom-start"
|
||||
@value-changed=${this._valueChanged}
|
||||
>
|
||||
@@ -173,20 +170,6 @@ export class HaIconPicker extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
// Allow preview for custom icon not in list
|
||||
if (rankedItems.length === 0) {
|
||||
rankedItems.push({
|
||||
item: {
|
||||
id: filter,
|
||||
primary: filter,
|
||||
icon: filter,
|
||||
search_labels: { keyword: filter },
|
||||
sorting_label: filter,
|
||||
},
|
||||
rank: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return rankedItems
|
||||
.sort((itemA, itemB) => itemA.rank - itemB.rank)
|
||||
.map((item) => item.item);
|
||||
|
||||
@@ -55,6 +55,7 @@ export interface PickerComboBoxItem {
|
||||
}
|
||||
|
||||
export const NO_ITEMS_AVAILABLE_ID = "___no_items_available___";
|
||||
const PADDING_ID = "___padding___";
|
||||
|
||||
const DEFAULT_ROW_RENDERER: RenderItemFunction<PickerComboBoxItem> = (
|
||||
item
|
||||
@@ -108,7 +109,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
public getItems!: (
|
||||
searchString?: string,
|
||||
section?: string
|
||||
) => (PickerComboBoxItem | string)[];
|
||||
) => PickerComboBoxItem[];
|
||||
|
||||
@property({ attribute: false, type: Array })
|
||||
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
|
||||
@@ -150,7 +151,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
@query("ha-textfield") private _searchFieldElement?: HaTextField;
|
||||
|
||||
@state() private _items: (PickerComboBoxItem | string)[] = [];
|
||||
@state() private _items: PickerComboBoxItem[] = [];
|
||||
|
||||
protected get scrollableElement(): HTMLElement | null {
|
||||
return this._virtualizerElement as HTMLElement | null;
|
||||
@@ -160,7 +161,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
@state() private _valuePinned = true;
|
||||
|
||||
private _allItems: (PickerComboBoxItem | string)[] = [];
|
||||
private _allItems: PickerComboBoxItem[] = [];
|
||||
|
||||
private _selectedItemIndex = -1;
|
||||
|
||||
@@ -278,8 +279,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
this._sectionTitle = this.sectionTitleFunction({
|
||||
firstIndex: ev.first,
|
||||
lastIndex: ev.last,
|
||||
firstItem: firstItem as PickerComboBoxItem | string,
|
||||
secondItem: secondItem as PickerComboBoxItem | string,
|
||||
firstItem: firstItem as PickerComboBoxItem,
|
||||
secondItem: secondItem as PickerComboBoxItem,
|
||||
itemsCount: this._virtualizerElement.items.length,
|
||||
});
|
||||
}
|
||||
@@ -323,28 +324,28 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
if (!items.length) {
|
||||
items.push(NO_ITEMS_AVAILABLE_ID);
|
||||
if (!items.length && !this.allowCustomValue) {
|
||||
items.push({ id: NO_ITEMS_AVAILABLE_ID, primary: "" });
|
||||
}
|
||||
|
||||
const additionalItems = this._getAdditionalItems();
|
||||
items.push(...additionalItems);
|
||||
|
||||
if (this.mode === "dialog") {
|
||||
items.push("padding"); // padding for safe area inset
|
||||
items.push({ id: PADDING_ID, primary: "" }); // padding for safe area inset
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
private _renderItem = (item: PickerComboBoxItem | string, index: number) => {
|
||||
private _renderItem = (item: PickerComboBoxItem, index: number) => {
|
||||
if (!item) {
|
||||
return nothing;
|
||||
}
|
||||
if (item === "padding") {
|
||||
if (item.id === PADDING_ID) {
|
||||
return html`<div class="bottom-padding"></div>`;
|
||||
}
|
||||
if (item === NO_ITEMS_AVAILABLE_ID) {
|
||||
if (item.id === NO_ITEMS_AVAILABLE_ID) {
|
||||
return html`
|
||||
<div class="combo-box-row">
|
||||
<ha-combo-box-item type="text" compact>
|
||||
@@ -419,21 +420,18 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = this._fuseIndex(
|
||||
this._allItems as PickerComboBoxItem[],
|
||||
this.searchKeys
|
||||
);
|
||||
const index = this._fuseIndex(this._allItems, this.searchKeys);
|
||||
|
||||
let filteredItems = multiTermSortedSearch<PickerComboBoxItem>(
|
||||
this._allItems as PickerComboBoxItem[],
|
||||
this._allItems,
|
||||
searchString,
|
||||
this.searchKeys || DEFAULT_SEARCH_KEYS,
|
||||
(item) => item.id,
|
||||
index
|
||||
) as (PickerComboBoxItem | string)[];
|
||||
);
|
||||
|
||||
if (!filteredItems.length) {
|
||||
filteredItems.push(NO_ITEMS_AVAILABLE_ID);
|
||||
if (!filteredItems.length && !this.allowCustomValue) {
|
||||
filteredItems.push({ id: NO_ITEMS_AVAILABLE_ID, primary: "" });
|
||||
}
|
||||
|
||||
const additionalItems = this._getAdditionalItems(searchString);
|
||||
@@ -442,8 +440,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
if (this.searchFn) {
|
||||
filteredItems = this.searchFn(
|
||||
searchString,
|
||||
filteredItems as PickerComboBoxItem[],
|
||||
this._allItems as PickerComboBoxItem[]
|
||||
filteredItems,
|
||||
this._allItems
|
||||
);
|
||||
}
|
||||
|
||||
@@ -459,7 +457,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
this._items = filteredItems as PickerComboBoxItem[];
|
||||
this._items = filteredItems;
|
||||
}
|
||||
|
||||
this._selectedItemIndex = -1;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { StateSelector } from "../../data/selector";
|
||||
import { extractFromTarget } from "../../data/target";
|
||||
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
|
||||
import "../entity/ha-entity-state-picker";
|
||||
import "../entity/ha-entity-states-picker";
|
||||
|
||||
@@ -32,6 +34,21 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _entityIds?: string | string[];
|
||||
|
||||
private _convertExtraOptions = memoizeOne(
|
||||
(
|
||||
extraOptions?: { label: string; value: any }[]
|
||||
): PickerComboBoxItem[] | undefined => {
|
||||
if (!extraOptions) {
|
||||
return undefined;
|
||||
}
|
||||
return extraOptions.map((option) => ({
|
||||
id: option.value,
|
||||
primary: option.label,
|
||||
sorting_label: option.label,
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
willUpdate(changedProps) {
|
||||
if (changedProps.has("selector") || changedProps.has("context")) {
|
||||
this._resolveEntityIds(
|
||||
@@ -45,6 +62,9 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const extraOptions = this._convertExtraOptions(
|
||||
this.selector.state?.extra_options
|
||||
);
|
||||
if (this.selector.state?.multiple) {
|
||||
return html`
|
||||
<ha-entity-states-picker
|
||||
@@ -52,7 +72,7 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
|
||||
.entityId=${this._entityIds}
|
||||
.attribute=${this.selector.state?.attribute ||
|
||||
this.context?.filter_attribute}
|
||||
.extraOptions=${this.selector.state?.extra_options}
|
||||
.extraOptions=${extraOptions}
|
||||
.value=${this.value}
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
@@ -69,7 +89,7 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
|
||||
.entityId=${this._entityIds}
|
||||
.attribute=${this.selector.state?.attribute ||
|
||||
this.context?.filter_attribute}
|
||||
.extraOptions=${this.selector.state?.extra_options}
|
||||
.extraOptions=${extraOptions}
|
||||
.value=${this.value}
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface RecorderInfo {
|
||||
|
||||
export type StatisticType = "change" | "state" | "sum" | "min" | "max" | "mean";
|
||||
|
||||
export type StatisticPeriod = "5minute" | "hour" | "day" | "week" | "month";
|
||||
|
||||
export type Statistics = Record<string, StatisticValue[]>;
|
||||
|
||||
export interface StatisticValue {
|
||||
@@ -174,7 +176,7 @@ export const fetchStatistics = (
|
||||
startTime: Date,
|
||||
endTime?: Date,
|
||||
statistic_ids?: string[],
|
||||
period: "5minute" | "hour" | "day" | "week" | "month" = "hour",
|
||||
period: StatisticPeriod = "hour",
|
||||
units?: StatisticsUnitConfiguration,
|
||||
types?: StatisticsTypes
|
||||
) =>
|
||||
|
||||
@@ -28,6 +28,7 @@ window.loadES5Adapter = () => {
|
||||
};
|
||||
|
||||
let panelEl: HTMLElement | undefined;
|
||||
let initialized = false;
|
||||
|
||||
function setProperties(properties) {
|
||||
if (!panelEl) {
|
||||
@@ -128,13 +129,23 @@ function initialize(
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
"DOMContentLoaded",
|
||||
() => window.parent.customPanel!.registerIframe(initialize, setProperties),
|
||||
{ once: true }
|
||||
);
|
||||
function handleReady() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
window.parent.customPanel!.registerIframe(initialize, setProperties);
|
||||
}
|
||||
|
||||
window.addEventListener("unload", () => {
|
||||
// Initial load
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", handleReady, { once: true });
|
||||
} else {
|
||||
handleReady();
|
||||
}
|
||||
|
||||
window.addEventListener("pageshow", handleReady);
|
||||
|
||||
window.addEventListener("pagehide", () => {
|
||||
initialized = false;
|
||||
// allow disconnected callback to fire
|
||||
while (document.body.lastChild) {
|
||||
document.body.removeChild(document.body.lastChild);
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { mdiAlert, mdiFormatListBulleted, mdiShape } from "@mdi/js";
|
||||
import {
|
||||
mdiAlert,
|
||||
mdiCodeBraces,
|
||||
mdiFormatListBulleted,
|
||||
mdiShape,
|
||||
} from "@mdi/js";
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, type nothing, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { ensureArray } from "../../../../common/array/ensure-array";
|
||||
import { transform } from "../../../../common/decorators/transform";
|
||||
import { isTemplate } from "../../../../common/string/has-template";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type { ConfigEntry } from "../../../../data/config_entries";
|
||||
import {
|
||||
@@ -167,6 +173,16 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the target is a template
|
||||
if (isTemplate(targetId)) {
|
||||
return this._renderTargetBadge(
|
||||
html`<ha-svg-icon .path=${mdiCodeBraces}></ha-svg-icon>`,
|
||||
this.localize(
|
||||
"ui.panel.config.automation.editor.target_summary.template"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const exists = this._checkTargetExists(targetType, targetId);
|
||||
if (!exists) {
|
||||
return this._renderTargetBadge(
|
||||
|
||||
@@ -5,8 +5,9 @@ import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { computeDeviceNameDisplay } from "../../../../common/entity/compute_device_name";
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-area-picker";
|
||||
import "../../../../components/ha-wa-dialog";
|
||||
import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-dialog";
|
||||
import "../../../../components/ha-labels-picker";
|
||||
import type { HaSwitch } from "../../../../components/ha-switch";
|
||||
import "../../../../components/ha-textfield";
|
||||
@@ -19,6 +20,8 @@ import type { DeviceRegistryDetailDialogParams } from "./show-dialog-device-regi
|
||||
class DialogDeviceRegistryDetail extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _nameByUser!: string;
|
||||
|
||||
@state() private _error?: string;
|
||||
@@ -42,10 +45,15 @@ class DialogDeviceRegistryDetail extends LitElement {
|
||||
this._areaId = this._params.device.area_id || "";
|
||||
this._labels = this._params.device.labels || [];
|
||||
this._disabledBy = this._params.device.disabled_by;
|
||||
this._open = true;
|
||||
await this.updateComplete;
|
||||
}
|
||||
|
||||
public closeDialog(): void {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._error = "";
|
||||
this._params = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
@@ -57,10 +65,12 @@ class DialogDeviceRegistryDetail extends LitElement {
|
||||
}
|
||||
const device = this._params.device;
|
||||
return html`
|
||||
<ha-dialog
|
||||
open
|
||||
@closed=${this.closeDialog}
|
||||
.heading=${computeDeviceNameDisplay(device, this.hass)}
|
||||
<ha-wa-dialog
|
||||
.hass=${this.hass}
|
||||
.open=${this._open}
|
||||
header-title=${computeDeviceNameDisplay(device, this.hass)}
|
||||
prevent-scrim-close
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<div>
|
||||
${this._error
|
||||
@@ -68,6 +78,7 @@ class DialogDeviceRegistryDetail extends LitElement {
|
||||
: ""}
|
||||
<div class="form">
|
||||
<ha-textfield
|
||||
autofocus
|
||||
.value=${this._nameByUser}
|
||||
@input=${this._nameChanged}
|
||||
.label=${this.hass.localize(
|
||||
@@ -75,7 +86,6 @@ class DialogDeviceRegistryDetail extends LitElement {
|
||||
)}
|
||||
.placeholder=${device.name || ""}
|
||||
.disabled=${this._submitting}
|
||||
dialogInitialFocus
|
||||
></ha-textfield>
|
||||
<ha-area-picker
|
||||
.hass=${this.hass}
|
||||
@@ -131,22 +141,25 @@ class DialogDeviceRegistryDetail extends LitElement {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
@click=${this.closeDialog}
|
||||
.disabled=${this._submitting}
|
||||
appearance="plain"
|
||||
>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._updateEntry}
|
||||
.disabled=${this._submitting}
|
||||
>
|
||||
${this.hass.localize("ui.dialogs.device-registry-detail.update")}
|
||||
</ha-button>
|
||||
</ha-dialog>
|
||||
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
@click=${this.closeDialog}
|
||||
.disabled=${this._submitting}
|
||||
appearance="plain"
|
||||
>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._updateEntry}
|
||||
.disabled=${this._submitting}
|
||||
>
|
||||
${this.hass.localize("ui.dialogs.device-registry-detail.update")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-wa-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -302,10 +302,11 @@ class DialogZWaveJSUpdateFirmwareNode extends LitElement {
|
||||
</div>
|
||||
${this._updateFinishedMessage!.success
|
||||
? html`<p>
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.zwave_js.update_firmware.finished_status.done${localizationKeySuffix}`
|
||||
)}
|
||||
</p>`
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.zwave_js.update_firmware.finished_status.done${localizationKeySuffix}`
|
||||
)}
|
||||
</p>
|
||||
${closeButton}`
|
||||
: html`<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zwave_js.update_firmware.finished_status.try_again"
|
||||
|
||||
@@ -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,36 @@ 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("_");
|
||||
|
||||
return {
|
||||
sizeClass: config.clock_size ? `size-${config.clock_size}` : "",
|
||||
isNumbers: faceParts?.includes("numbers") ?? false,
|
||||
isRoman: faceParts?.includes("roman") ?? false,
|
||||
isUpright: faceParts?.includes("upright") ?? false,
|
||||
};
|
||||
});
|
||||
|
||||
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 } = this._computeClock(
|
||||
this.config
|
||||
);
|
||||
|
||||
const indicator = (number?: number) => html`
|
||||
<div
|
||||
@@ -163,14 +241,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 +256,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 +288,27 @@ export class HuiClockCardAnalog extends LitElement {
|
||||
`
|
||||
)
|
||||
: nothing}
|
||||
${this.config?.date && this.config.date !== "none"
|
||||
? html`<div class="date-parts ${sizeClass}">
|
||||
<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 +317,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>
|
||||
@@ -407,6 +502,41 @@ 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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.date-part.year {
|
||||
grid-area: year;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,35 +30,33 @@ import {
|
||||
import { formatTime } from "../../../../../common/datetime/format_time";
|
||||
import type { ECOption } from "../../../../../resources/echarts/echarts";
|
||||
import { filterXSS } from "../../../../../common/util/xss";
|
||||
import type { StatisticPeriod } from "../../../../../data/recorder";
|
||||
|
||||
export function getSuggestedMax(
|
||||
dayDifference: number,
|
||||
end: Date,
|
||||
detailedDailyData = false
|
||||
): number {
|
||||
export function getSuggestedMax(period: StatisticPeriod, end: Date): number {
|
||||
let suggestedMax = new Date(end);
|
||||
|
||||
if (period === "5minute") {
|
||||
return suggestedMax.getTime();
|
||||
}
|
||||
suggestedMax.setMinutes(0, 0, 0);
|
||||
if (period === "hour") {
|
||||
return suggestedMax.getTime();
|
||||
}
|
||||
// Sometimes around DST we get a time of 0:59 instead of 23:59 as expected.
|
||||
// Correct for this when showing days/months so we don't get an extra day.
|
||||
if (dayDifference > 2 && suggestedMax.getHours() === 0) {
|
||||
if (suggestedMax.getHours() === 0) {
|
||||
suggestedMax = subHours(suggestedMax, 1);
|
||||
}
|
||||
|
||||
if (!detailedDailyData) {
|
||||
suggestedMax.setMinutes(0, 0, 0);
|
||||
}
|
||||
if (dayDifference > 35) {
|
||||
suggestedMax.setDate(1);
|
||||
}
|
||||
if (dayDifference > 2) {
|
||||
suggestedMax.setHours(0);
|
||||
suggestedMax.setHours(0);
|
||||
if (period === "day" || period === "week") {
|
||||
return suggestedMax.getTime();
|
||||
}
|
||||
// period === month
|
||||
suggestedMax.setDate(1);
|
||||
return suggestedMax.getTime();
|
||||
}
|
||||
|
||||
export function getSuggestedPeriod(
|
||||
dayDifference: number
|
||||
): "month" | "day" | "hour" {
|
||||
export function getSuggestedPeriod(dayDifference: number): StatisticPeriod {
|
||||
return dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour";
|
||||
}
|
||||
|
||||
@@ -96,7 +94,10 @@ export function getCommonOptions(
|
||||
xAxis: {
|
||||
type: "time",
|
||||
min: start,
|
||||
max: getSuggestedMax(dayDifference, end, detailedDailyData),
|
||||
max: getSuggestedMax(
|
||||
detailedDailyData ? "5minute" : getSuggestedPeriod(dayDifference),
|
||||
end
|
||||
),
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
|
||||
@@ -334,10 +334,7 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
|
||||
.maxYAxis=${this._config.max_y_axis}
|
||||
.startTime=${this._energyStart}
|
||||
.endTime=${this._energyEnd && this._energyStart
|
||||
? getSuggestedMax(
|
||||
differenceInDays(this._energyEnd, this._energyStart),
|
||||
this._energyEnd
|
||||
)
|
||||
? getSuggestedMax(this._period!, this._energyEnd)
|
||||
: undefined}
|
||||
.fitYData=${this._config.fit_y_data || false}
|
||||
.hideLegend=${this._config.hide_legend || false}
|
||||
|
||||
@@ -417,6 +417,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";
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -137,9 +137,9 @@
|
||||
},
|
||||
"counter": {
|
||||
"actions": {
|
||||
"increment": "increment",
|
||||
"decrement": "decrement",
|
||||
"reset": "reset"
|
||||
"increment": "Increment",
|
||||
"decrement": "Decrement",
|
||||
"reset": "Reset"
|
||||
}
|
||||
},
|
||||
"cover": {
|
||||
@@ -672,8 +672,8 @@
|
||||
"device_missing": "No related device"
|
||||
},
|
||||
"add": "Add",
|
||||
"custom_name": "Custom name",
|
||||
"no_match": "No entities found"
|
||||
"search": "Search or enter custom name",
|
||||
"custom_name": "Custom name"
|
||||
},
|
||||
"entity-attribute-picker": {
|
||||
"attribute": "Attribute",
|
||||
@@ -685,7 +685,7 @@
|
||||
},
|
||||
"entity-state-content-picker": {
|
||||
"add": "Add",
|
||||
"custom_state": "Custom state"
|
||||
"custom_attribute": "Custom attribute"
|
||||
}
|
||||
},
|
||||
"target-picker": {
|
||||
@@ -772,9 +772,6 @@
|
||||
"no_match": "No languages found for {term}",
|
||||
"no_languages": "No languages available"
|
||||
},
|
||||
"icon-picker": {
|
||||
"no_match": "No matching icons found"
|
||||
},
|
||||
"tts-picker": {
|
||||
"tts": "Text-to-speech",
|
||||
"none": "None"
|
||||
@@ -4119,7 +4116,8 @@
|
||||
"targets": "{count} {count, plural,\n one {target}\n other {targets}\n}",
|
||||
"invalid": "Invalid target",
|
||||
"all_entities": "All entities",
|
||||
"none_entities": "No entities"
|
||||
"none_entities": "No entities",
|
||||
"template": "Template"
|
||||
},
|
||||
"triggers": {
|
||||
"name": "Triggers",
|
||||
@@ -7345,7 +7343,7 @@
|
||||
"energy_usage_graph": {
|
||||
"total_consumed": "Total consumed {num} kWh",
|
||||
"total_returned": "Total returned {num} kWh",
|
||||
"total_usage": "{num} kWh used",
|
||||
"total_usage": "+{num} kWh",
|
||||
"combined_from_grid": "Combined from grid",
|
||||
"consumed_solar": "Consumed solar",
|
||||
"consumed_battery": "Consumed battery"
|
||||
@@ -8213,6 +8211,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",
|
||||
|
||||
Reference in New Issue
Block a user