mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-15 16:54:03 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d85ec4ed61 |
@@ -1,16 +1,7 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import type { Lovelace } from "../../../src/panels/lovelace/types";
|
||||
import { energyEntities } from "../stubs/entities";
|
||||
import { getDemoTheme } from "../stubs/frontend";
|
||||
import type { DemoConfig, DemoTheme } from "./types";
|
||||
|
||||
export const applyDemoTheme = (hass: MockHomeAssistant, theme: DemoTheme) => {
|
||||
if (typeof theme === "function") {
|
||||
hass.mockTheme(theme());
|
||||
return;
|
||||
}
|
||||
hass.mockTheme(null, getDemoTheme(theme));
|
||||
};
|
||||
import type { DemoConfig } from "./types";
|
||||
|
||||
export const demoConfigs: (() => Promise<DemoConfig>)[] = [
|
||||
() => import("./sections").then((mod) => mod.demoSections),
|
||||
@@ -40,5 +31,5 @@ export const setDemoConfig = async (
|
||||
hass.addEntities(config.entities(hass.localize), true);
|
||||
hass.addEntities(energyEntities());
|
||||
lovelace.saveConfig(config.lovelace(hass.localize));
|
||||
applyDemoTheme(hass, config.theme);
|
||||
hass.mockTheme(config.theme());
|
||||
};
|
||||
|
||||
@@ -8,5 +8,5 @@ export const demoSections: DemoConfig = {
|
||||
name: "Home Demo",
|
||||
lovelace: demoLovelaceSections,
|
||||
entities: demoEntitiesSections,
|
||||
theme: { theme: "default", dark: false },
|
||||
theme: () => ({}),
|
||||
};
|
||||
|
||||
@@ -2,9 +2,6 @@ import type { TemplateResult } from "lit";
|
||||
import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
import type { LovelaceConfig } from "../../../src/data/lovelace/config/types";
|
||||
import type { EntityInput } from "../../../src/fake_data/entities/types";
|
||||
import type { ThemeSettings } from "../../../src/types";
|
||||
|
||||
export type DemoTheme = ThemeSettings | (() => Record<string, string> | null);
|
||||
|
||||
export interface DemoConfig {
|
||||
index?: number;
|
||||
@@ -15,5 +12,5 @@ export interface DemoConfig {
|
||||
string | ((localize: LocalizeFunc) => string | TemplateResult<1>);
|
||||
lovelace: (localize: LocalizeFunc) => LovelaceConfig;
|
||||
entities: (localize: LocalizeFunc) => EntityInput[];
|
||||
theme: DemoTheme;
|
||||
theme: () => Record<string, string> | null;
|
||||
}
|
||||
|
||||
+4
-2
@@ -5,7 +5,7 @@ import type { MockHomeAssistant } from "../../src/fake_data/provide_hass";
|
||||
import { provideHass } from "../../src/fake_data/provide_hass";
|
||||
import { HomeAssistantAppEl } from "../../src/layouts/home-assistant";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
import { applyDemoTheme, selectedDemoConfig } from "./configs/demo-configs";
|
||||
import { selectedDemoConfig } from "./configs/demo-configs";
|
||||
import { mockAreaRegistry } from "./stubs/area_registry";
|
||||
import { mockAuth } from "./stubs/auth";
|
||||
import { demoDevices } from "./stubs/devices";
|
||||
@@ -173,7 +173,9 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
Promise.all([selectedDemoConfig, localizePromise]).then(
|
||||
([conf, localize]) => {
|
||||
hass.addEntities(conf.entities(localize));
|
||||
applyDemoTheme(hass, conf.theme);
|
||||
if (conf.theme) {
|
||||
hass.mockTheme(conf.theme());
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,37 +1,10 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import type { ThemeSettings } from "../../../src/types";
|
||||
|
||||
let sidebarChangeCallback: ((data: { value: unknown }) => void) | undefined;
|
||||
let themeChangeCallback: ((data: { value: ThemeSettings }) => void) | undefined;
|
||||
|
||||
const THEME_STORAGE_KEY = "demo_theme";
|
||||
const DEFAULT_THEME: ThemeSettings = { theme: "default", dark: false };
|
||||
|
||||
export const getDemoTheme = (
|
||||
fallback: ThemeSettings = DEFAULT_THEME
|
||||
): ThemeSettings => {
|
||||
const storedTheme = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (!storedTheme) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(storedTheme) as ThemeSettings;
|
||||
} catch {
|
||||
localStorage.removeItem(THEME_STORAGE_KEY);
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
let sidebarChangeCallback;
|
||||
|
||||
export const mockFrontend = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("frontend/get_user_data", ({ key }) => ({
|
||||
value: key === "theme" ? getDemoTheme() : null,
|
||||
}));
|
||||
hass.mockWS("frontend/get_user_data", () => ({ value: null }));
|
||||
hass.mockWS("frontend/set_user_data", ({ key, value }) => {
|
||||
if (key === "theme") {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(value));
|
||||
themeChangeCallback?.({ value });
|
||||
localStorage.removeItem("selectedTheme");
|
||||
}
|
||||
if (key === "sidebar") {
|
||||
sidebarChangeCallback?.({
|
||||
value: {
|
||||
@@ -41,18 +14,10 @@ export const mockFrontend = (hass: MockHomeAssistant) => {
|
||||
});
|
||||
}
|
||||
});
|
||||
hass.mockWS("frontend/subscribe_user_data", (msg, currentHass, onChange) => {
|
||||
hass.mockWS("frontend/subscribe_user_data", (msg, _hass, onChange) => {
|
||||
if (msg.key === "sidebar") {
|
||||
sidebarChangeCallback = onChange;
|
||||
}
|
||||
if (msg.key === "theme") {
|
||||
themeChangeCallback = onChange;
|
||||
onChange?.({
|
||||
value: getDemoTheme(currentHass.selectedTheme ?? undefined),
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return () => {};
|
||||
}
|
||||
onChange?.({ value: null });
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return () => {};
|
||||
|
||||
+2
-3
@@ -12,7 +12,7 @@
|
||||
"format:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
|
||||
"lint:prettier": "prettier . --cache --check",
|
||||
"format:prettier": "prettier . --cache --write",
|
||||
"lint:types": "node ./node_modules/@typescript/native/bin/tsc",
|
||||
"lint:types": "tsc",
|
||||
"lint:lit": "lit-analyzer \"{.,*}/src/**/*.ts\"",
|
||||
"lint:licenses": "node --no-deprecation script/check-licenses",
|
||||
"lint": "yarn run lint:eslint && yarn run lint:prettier && yarn run lint:types && yarn run lint:lit",
|
||||
@@ -168,7 +168,6 @@
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@typescript/native": "npm:typescript@7.0.2",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
@@ -207,7 +206,7 @@
|
||||
"rspack-manifest-plugin": "5.2.2",
|
||||
"serve": "14.2.6",
|
||||
"sinon": "22.0.0",
|
||||
"tar": "7.5.20",
|
||||
"tar": "7.5.19",
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
|
||||
@@ -1,4 +1,53 @@
|
||||
const regexp =
|
||||
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
// Unanchored regex fragments, shared as the single source of truth for both
|
||||
// the boolean validators below and the HTML `pattern` attribute (the browser
|
||||
// anchors a pattern as `^(?:…)$`).
|
||||
const IPV4 =
|
||||
"(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)";
|
||||
|
||||
export const isIPAddress = (input: string): boolean => regexp.test(input);
|
||||
const IPV6 =
|
||||
"(?:([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))";
|
||||
|
||||
// CIDR prefix lengths: 0-32 for IPv4, 0-128 for IPv6.
|
||||
const IPV4_PREFIX = "(?:3[0-2]|[12]?[0-9])";
|
||||
const IPV6_PREFIX = "(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])";
|
||||
|
||||
// IPv4 or IPv6 address.
|
||||
export const IP_ADDRESS_PATTERN = `${IPV4}|${IPV6}`;
|
||||
|
||||
// IPv4/IPv6 address, optionally with a CIDR prefix (network).
|
||||
export const IP_ADDRESS_OR_NETWORK_PATTERN = `${IPV4}(?:/${IPV4_PREFIX})?|${IPV6}(?:/${IPV6_PREFIX})?`;
|
||||
|
||||
const anchored = (pattern: string): RegExp => new RegExp(`^(?:${pattern})$`);
|
||||
|
||||
const ipv4Regexp = anchored(IPV4);
|
||||
const ipv6Regexp = anchored(IPV6);
|
||||
|
||||
// IPv4 address, e.g. 192.168.1.10
|
||||
export const isIPAddress = (input: string): boolean => ipv4Regexp.test(input);
|
||||
|
||||
// IPv6 address, e.g. fe80::85d:e82c:9446:7995
|
||||
export const isIPv6Address = (input: string): boolean => ipv6Regexp.test(input);
|
||||
|
||||
// IPv4 or IPv6 address
|
||||
export const isIPAddressV4OrV6 = (input: string): boolean =>
|
||||
isIPAddress(input) || isIPv6Address(input);
|
||||
|
||||
// IP network in CIDR notation, e.g. 192.168.1.0/24 or fd00::/8
|
||||
export const isIPNetwork = (input: string): boolean => {
|
||||
const parts = input.split("/");
|
||||
if (parts.length !== 2) {
|
||||
return false;
|
||||
}
|
||||
const [address, prefix] = parts;
|
||||
if (!/^\d{1,3}$/.test(prefix)) {
|
||||
return false;
|
||||
}
|
||||
const prefixLength = Number(prefix);
|
||||
if (isIPAddress(address)) {
|
||||
return prefixLength >= 0 && prefixLength <= 32;
|
||||
}
|
||||
if (isIPv6Address(address)) {
|
||||
return prefixLength >= 0 && prefixLength <= 128;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -1,540 +0,0 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import { resolveTimeZone } from "../common/datetime/resolve-time-zone";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { configContext, internationalizationContext } from "../data/context";
|
||||
import {
|
||||
CLOCK_CARD_DATE_PARTS,
|
||||
formatClockCardDate,
|
||||
} from "../panels/lovelace/cards/clock/clock-date-format";
|
||||
import type { ClockCardDatePart } from "../panels/lovelace/cards/types";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import "./chips/ha-assist-chip";
|
||||
import "./chips/ha-chip-set";
|
||||
import "./chips/ha-input-chip";
|
||||
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 "./ha-sortable";
|
||||
|
||||
type ClockDatePartSection = "weekday" | "day" | "month" | "year" | "separator";
|
||||
|
||||
type ClockDateSeparatorPart = Extract<
|
||||
ClockCardDatePart,
|
||||
"separator-dash" | "separator-slash" | "separator-dot" | "separator-new-line"
|
||||
>;
|
||||
|
||||
const CLOCK_DATE_PART_SECTION_ORDER: readonly ClockDatePartSection[] = [
|
||||
"day",
|
||||
"month",
|
||||
"year",
|
||||
"weekday",
|
||||
"separator",
|
||||
];
|
||||
|
||||
const CLOCK_DATE_SEPARATOR_VALUES: Record<ClockDateSeparatorPart, string> = {
|
||||
"separator-dash": "-",
|
||||
"separator-slash": "/",
|
||||
"separator-dot": ".",
|
||||
"separator-new-line": "",
|
||||
};
|
||||
|
||||
const getClockDatePartSection = (
|
||||
part: ClockCardDatePart
|
||||
): ClockDatePartSection => {
|
||||
if (part.startsWith("weekday-")) {
|
||||
return "weekday";
|
||||
}
|
||||
|
||||
if (part.startsWith("day-")) {
|
||||
return "day";
|
||||
}
|
||||
|
||||
if (part.startsWith("month-")) {
|
||||
return "month";
|
||||
}
|
||||
|
||||
if (part.startsWith("year-")) {
|
||||
return "year";
|
||||
}
|
||||
|
||||
return "separator";
|
||||
};
|
||||
|
||||
interface ClockDatePartSectionData {
|
||||
id: ClockDatePartSection;
|
||||
title: string;
|
||||
items: PickerComboBoxItem[];
|
||||
}
|
||||
|
||||
interface ClockDatePartValueItem {
|
||||
key: string;
|
||||
item: string;
|
||||
idx: number;
|
||||
}
|
||||
|
||||
@customElement("ha-clock-date-format-picker")
|
||||
export class HaClockDateFormatPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = false;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public value?: string[] | string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _hassConfig!: ContextType<typeof configContext>;
|
||||
|
||||
@query("ha-generic-picker", true) private _picker?: HaGenericPicker;
|
||||
|
||||
private _editIndex?: number;
|
||||
|
||||
protected render() {
|
||||
const value = this._value;
|
||||
const valueItems = this._getValueItems(value);
|
||||
const sections = this._buildSections();
|
||||
|
||||
return html`
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required && !value.length}
|
||||
.value=${this._getPickerValue()}
|
||||
.sections=${this._getSectionHeaders(sections)}
|
||||
.getItems=${this._getItems(sections)}
|
||||
@value-changed=${this._pickerValueChanged}
|
||||
>
|
||||
<div slot="field" class="container">
|
||||
<ha-sortable
|
||||
no-style
|
||||
@item-moved=${this._moveItem}
|
||||
.disabled=${this.disabled}
|
||||
handle-selector="button.primary.action"
|
||||
filter=".add"
|
||||
>
|
||||
<ha-chip-set>
|
||||
${repeat(
|
||||
valueItems,
|
||||
(entry: ClockDatePartValueItem) => entry.key,
|
||||
({ item, idx }) => this._renderValueChip(item, idx, sections)
|
||||
)}
|
||||
${
|
||||
this.disabled
|
||||
? nothing
|
||||
: html`
|
||||
<ha-assist-chip
|
||||
@click=${this._addItem}
|
||||
.disabled=${this.disabled}
|
||||
label=${this._i18n.localize("ui.common.add")}
|
||||
class="add"
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
`
|
||||
}
|
||||
</ha-chip-set>
|
||||
</ha-sortable>
|
||||
</div>
|
||||
</ha-generic-picker>
|
||||
${this._renderHelper()}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderHelper() {
|
||||
return this.helper
|
||||
? html`
|
||||
<ha-input-helper-text .disabled=${this.disabled}>
|
||||
${this.helper}
|
||||
</ha-input-helper-text>
|
||||
`
|
||||
: nothing;
|
||||
}
|
||||
|
||||
private _getValueItems = memoizeOne(
|
||||
(value: string[]): ClockDatePartValueItem[] => {
|
||||
const occurrences = new Map<string, number>();
|
||||
|
||||
return value.map((item, idx) => {
|
||||
const occurrence = occurrences.get(item) ?? 0;
|
||||
occurrences.set(item, occurrence + 1);
|
||||
|
||||
return {
|
||||
key: `${item}:${occurrence}`,
|
||||
item,
|
||||
idx,
|
||||
};
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
private _renderValueChip(
|
||||
item: string,
|
||||
idx: number,
|
||||
sections: ClockDatePartSectionData[]
|
||||
) {
|
||||
const label = this._getItemLabel(item, sections);
|
||||
const isValid = !!label;
|
||||
|
||||
return html`
|
||||
<ha-input-chip
|
||||
data-idx=${idx}
|
||||
@remove=${this._removeItem}
|
||||
@click=${this._editItem}
|
||||
.label=${label ?? item}
|
||||
.selected=${!this.disabled}
|
||||
.disabled=${this.disabled}
|
||||
class=${!isValid ? "invalid" : ""}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
</ha-input-chip>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _addItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._editIndex = undefined;
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
}
|
||||
|
||||
private async _editItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = parseInt(
|
||||
(ev.currentTarget as HTMLElement).dataset.idx ?? "",
|
||||
10
|
||||
);
|
||||
this._editIndex = idx;
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
}
|
||||
|
||||
private get _value() {
|
||||
return !this.value ? [] : ensureArray(this.value);
|
||||
}
|
||||
|
||||
private _toValue = memoizeOne((value: string[]): string[] | undefined =>
|
||||
value.length === 0 ? undefined : value
|
||||
);
|
||||
|
||||
private _buildSections(): ClockDatePartSectionData[] {
|
||||
const itemsBySection: Record<ClockDatePartSection, PickerComboBoxItem[]> = {
|
||||
weekday: [],
|
||||
day: [],
|
||||
month: [],
|
||||
year: [],
|
||||
separator: [],
|
||||
};
|
||||
|
||||
const previewDate = new Date();
|
||||
const previewTimeZone = resolveTimeZone(
|
||||
this._i18n.locale.time_zone,
|
||||
this._hassConfig.config.time_zone
|
||||
);
|
||||
|
||||
CLOCK_CARD_DATE_PARTS.forEach((part) => {
|
||||
const section = getClockDatePartSection(part);
|
||||
const label =
|
||||
this._i18n.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.date.parts.${part}`
|
||||
) ?? part;
|
||||
|
||||
const secondary =
|
||||
section === "separator"
|
||||
? CLOCK_DATE_SEPARATOR_VALUES[part as ClockDateSeparatorPart]
|
||||
: formatClockCardDate(
|
||||
previewDate,
|
||||
{ parts: [part] },
|
||||
this._i18n.locale.language,
|
||||
previewTimeZone
|
||||
);
|
||||
|
||||
itemsBySection[section].push({
|
||||
id: part,
|
||||
primary: label,
|
||||
secondary,
|
||||
sorting_label: label,
|
||||
});
|
||||
});
|
||||
|
||||
return CLOCK_DATE_PART_SECTION_ORDER.map((section) => ({
|
||||
id: section,
|
||||
title:
|
||||
this._i18n.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.date.sections.${section}`
|
||||
) ?? section,
|
||||
items: itemsBySection[section],
|
||||
})).filter((section) => section.items.length > 0);
|
||||
}
|
||||
|
||||
private _getSectionHeaders(
|
||||
sections: ClockDatePartSectionData[]
|
||||
): { id: string; label: string }[] {
|
||||
return sections.map((section) => ({
|
||||
id: section.id,
|
||||
label: section.title,
|
||||
}));
|
||||
}
|
||||
|
||||
private _getItems = memoizeOne(
|
||||
(sections: ClockDatePartSectionData[]) =>
|
||||
(
|
||||
searchString?: string,
|
||||
section?: string
|
||||
): (PickerComboBoxItem | string)[] => {
|
||||
const normalizedSearch = searchString?.trim().toLowerCase();
|
||||
|
||||
const filteredSections = sections
|
||||
.map((sectionData) => {
|
||||
if (!normalizedSearch) {
|
||||
return sectionData;
|
||||
}
|
||||
|
||||
return {
|
||||
...sectionData,
|
||||
items: sectionData.items.filter(
|
||||
(item) =>
|
||||
item.primary.toLowerCase().includes(normalizedSearch) ||
|
||||
item.secondary?.toLowerCase().includes(normalizedSearch) ||
|
||||
item.id.toLowerCase().includes(normalizedSearch)
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((sectionData) => sectionData.items.length > 0);
|
||||
|
||||
if (section) {
|
||||
return (
|
||||
filteredSections.find((candidate) => candidate.id === section)
|
||||
?.items || []
|
||||
);
|
||||
}
|
||||
|
||||
const groupedItems: (PickerComboBoxItem | string)[] = [];
|
||||
|
||||
filteredSections.forEach((sectionData) => {
|
||||
groupedItems.push(sectionData.title, ...sectionData.items);
|
||||
});
|
||||
|
||||
return groupedItems;
|
||||
}
|
||||
);
|
||||
|
||||
private _getItemLabel(
|
||||
value: string,
|
||||
sections: ClockDatePartSectionData[]
|
||||
): string | undefined {
|
||||
for (const section of sections) {
|
||||
const item = section.items.find((candidate) => candidate.id === value);
|
||||
|
||||
if (item) {
|
||||
if (section.id === "separator") {
|
||||
if (value === "separator-new-line") {
|
||||
return item.primary;
|
||||
}
|
||||
|
||||
return item.secondary ?? item.primary;
|
||||
}
|
||||
|
||||
return `${item.secondary} [${item.primary} ${section.title}]`;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _getPickerValue(): string | undefined {
|
||||
if (this._editIndex != null) {
|
||||
return this._value[this._editIndex];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async _moveItem(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const { oldIndex, newIndex } = ev.detail;
|
||||
|
||||
const value = this._value;
|
||||
const newValue = value.concat();
|
||||
const element = newValue.splice(oldIndex, 1)[0];
|
||||
newValue.splice(newIndex, 0, element);
|
||||
|
||||
this._setValue(newValue);
|
||||
}
|
||||
|
||||
private async _removeItem(ev: Event) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
const idx = parseInt(
|
||||
(ev.currentTarget as HTMLElement).dataset.idx ?? "",
|
||||
10
|
||||
);
|
||||
|
||||
if (Number.isNaN(idx)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = [...this._value];
|
||||
value.splice(idx, 1);
|
||||
|
||||
if (this._editIndex !== undefined) {
|
||||
if (this._editIndex === idx) {
|
||||
this._editIndex = undefined;
|
||||
} else if (this._editIndex > idx) {
|
||||
this._editIndex -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
this._setValue(value);
|
||||
}
|
||||
|
||||
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value;
|
||||
|
||||
if (this.disabled || !value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = [...this._value];
|
||||
|
||||
if (this._editIndex != null) {
|
||||
newValue[this._editIndex] = value;
|
||||
this._editIndex = undefined;
|
||||
} else {
|
||||
newValue.push(value);
|
||||
}
|
||||
|
||||
this._setValue(newValue);
|
||||
|
||||
if (this._picker) {
|
||||
this._picker.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _setValue(value: string[]) {
|
||||
const newValue = this._toValue(value);
|
||||
this.value = newValue;
|
||||
|
||||
fireEvent(this, "value-changed", {
|
||||
value: newValue,
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
background-color: var(--mdc-text-field-fill-color, whitesmoke);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
border-end-end-radius: var(--ha-border-radius-square);
|
||||
border-end-start-radius: var(--ha-border-radius-square);
|
||||
}
|
||||
|
||||
.container:after {
|
||||
display: block;
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background-color: var(
|
||||
--mdc-text-field-idle-line-color,
|
||||
rgba(0, 0, 0, 0.42)
|
||||
);
|
||||
transition:
|
||||
height 180ms ease-in-out,
|
||||
background-color 180ms ease-in-out;
|
||||
}
|
||||
|
||||
:host([disabled]) .container:after {
|
||||
background-color: var(
|
||||
--mdc-text-field-disabled-line-color,
|
||||
rgba(0, 0, 0, 0.42)
|
||||
);
|
||||
}
|
||||
|
||||
.container:focus-within:after {
|
||||
height: 2px;
|
||||
background-color: var(--mdc-theme-primary);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 0 0 var(--ha-space-2);
|
||||
}
|
||||
|
||||
.add {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
ha-chip-set {
|
||||
padding: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.invalid {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.sortable-fallback {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sortable-ghost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.sortable-drag {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
ha-input-helper-text {
|
||||
display: block;
|
||||
margin: var(--ha-space-2) 0 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-clock-date-format-picker": HaClockDateFormatPicker;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,10 @@ export class HaTextSelector extends LitElement {
|
||||
|
||||
@query("ha-input, ha-textarea") private _input?: HTMLInputElement;
|
||||
|
||||
@query("ha-input-multi") private _inputMulti?: {
|
||||
reportValidity: () => boolean;
|
||||
};
|
||||
|
||||
public async focus() {
|
||||
await this.updateComplete;
|
||||
this._input?.focus();
|
||||
@@ -35,7 +39,7 @@ export class HaTextSelector extends LitElement {
|
||||
|
||||
public reportValidity(): boolean {
|
||||
if (this.selector.text?.multiple) {
|
||||
return true;
|
||||
return this._inputMulti?.reportValidity() ?? true;
|
||||
}
|
||||
return this._input?.reportValidity() ?? true;
|
||||
}
|
||||
@@ -52,6 +56,8 @@ export class HaTextSelector extends LitElement {
|
||||
.inputPrefix=${this.selector.text?.prefix}
|
||||
.helper=${this.helper}
|
||||
.autocomplete=${this.selector.text?.autocomplete}
|
||||
.pattern=${this.selector.text?.pattern}
|
||||
.validationMessage=${this.selector.text?.validation_message}
|
||||
@value-changed=${this._handleChange}
|
||||
>
|
||||
</ha-input-multi>
|
||||
@@ -80,6 +86,9 @@ export class HaTextSelector extends LitElement {
|
||||
.hint=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.type=${this.selector.text?.type}
|
||||
.pattern=${this.selector.text?.pattern}
|
||||
.validationMessage=${this.selector.text?.validation_message}
|
||||
.autoValidate=${this.selector.text?.pattern !== undefined}
|
||||
@input=${this._handleChange}
|
||||
@change=${this._handleChange}
|
||||
.label=${this.label || ""}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { UiClockDateFormatSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-clock-date-format-picker";
|
||||
|
||||
@customElement("ha-selector-ui_clock_date_format")
|
||||
export class HaSelectorUiClockDateFormat extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: UiClockDateFormatSelector;
|
||||
|
||||
@property() public value?: string | string[];
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-clock-date-format-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this.value}
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
></ha-clock-date-format-picker>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-selector-ui_clock_date_format": HaSelectorUiClockDateFormat;
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,6 @@ const LOAD_ELEMENTS = {
|
||||
location: () => import("./ha-selector-location"),
|
||||
color_temp: () => import("./ha-selector-color-temp"),
|
||||
ui_action: () => import("./ha-selector-ui-action"),
|
||||
ui_clock_date_format: () => import("./ha-selector-ui-clock-date-format"),
|
||||
ui_color: () => import("./ha-selector-ui-color"),
|
||||
ui_state_content: () => import("./ha-selector-ui-state-content"),
|
||||
ui_time_format: () => import("./ha-selector-ui-time-format"),
|
||||
|
||||
@@ -2,7 +2,13 @@ import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiDeleteOutline, mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import {
|
||||
customElement,
|
||||
property,
|
||||
query,
|
||||
queryAll,
|
||||
state,
|
||||
} from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { uid } from "../../common/util/uid";
|
||||
@@ -50,6 +56,13 @@ class HaInputMulti extends LitElement {
|
||||
|
||||
@property() public autocomplete?: string;
|
||||
|
||||
/** Regular expression each entry is validated against (HTML `pattern`). */
|
||||
@property() public pattern?: string;
|
||||
|
||||
/** Message shown on an entry when it fails `pattern` validation. */
|
||||
@property({ attribute: "validation-message" })
|
||||
public validationMessage?: string;
|
||||
|
||||
@property({ attribute: "add-label" }) public addLabel?: string;
|
||||
|
||||
@property({ attribute: "remove-label" }) public removeLabel?: string;
|
||||
@@ -70,6 +83,8 @@ class HaInputMulti extends LitElement {
|
||||
|
||||
@query("ha-input[data-last]") private _lastInput?: HaInput;
|
||||
|
||||
@queryAll("ha-input") private _inputs?: NodeListOf<HaInput>;
|
||||
|
||||
// Stable key per row, kept in sync with `value`. Because items are plain
|
||||
// strings we cannot use a WeakMap (as the object-based sortable lists do),
|
||||
// so we track keys in a parallel array. Keys stay fixed while a row is
|
||||
@@ -89,6 +104,16 @@ class HaInputMulti extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
public reportValidity(): boolean {
|
||||
let valid = true;
|
||||
this._inputs?.forEach((input) => {
|
||||
if (!input.reportValidity()) {
|
||||
valid = false;
|
||||
}
|
||||
});
|
||||
return valid;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-sortable
|
||||
@@ -109,6 +134,9 @@ class HaInputMulti extends LitElement {
|
||||
.type=${this.inputType}
|
||||
.autocomplete=${this.autocomplete}
|
||||
.disabled=${this.disabled}
|
||||
.pattern=${this.pattern}
|
||||
.validationMessage=${this.validationMessage}
|
||||
.autoValidate=${this.pattern !== undefined}
|
||||
dialogInitialFocus=${index}
|
||||
.index=${index}
|
||||
class="flex-auto"
|
||||
|
||||
@@ -17,6 +17,9 @@ export interface HassioStats {
|
||||
network_tx: number;
|
||||
}
|
||||
|
||||
export const hassioApiResultExtractor = <T>(response: HassioResponse<T>) =>
|
||||
response.data;
|
||||
|
||||
export const extractApiErrorMessage = (error: any): string =>
|
||||
typeof error === "object"
|
||||
? typeof error.body === "object"
|
||||
|
||||
+47
-17
@@ -1,4 +1,7 @@
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassioResponse } from "./common";
|
||||
import { hassioApiResultExtractor } from "./common";
|
||||
|
||||
type HassioDockerRegistries = Record<
|
||||
string,
|
||||
@@ -7,32 +10,59 @@ type HassioDockerRegistries = Record<
|
||||
|
||||
export const fetchHassioDockerRegistries = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<HassioDockerRegistries> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/docker/registries`,
|
||||
method: "get",
|
||||
});
|
||||
): Promise<HassioDockerRegistries> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/docker/registries`,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<HassioDockerRegistries>>(
|
||||
"GET",
|
||||
"hassio/docker/registries"
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const addHassioDockerRegistry = async (
|
||||
hass: HomeAssistant,
|
||||
data: HassioDockerRegistries
|
||||
) => {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/docker/registries`,
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/docker/registries`,
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<HassioDockerRegistries>>(
|
||||
"POST",
|
||||
"hassio/docker/registries",
|
||||
data
|
||||
);
|
||||
};
|
||||
|
||||
export const removeHassioDockerRegistry = async (
|
||||
hass: HomeAssistant,
|
||||
registry: string
|
||||
) => {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/docker/registries/${registry}`,
|
||||
method: "delete",
|
||||
});
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/docker/registries/${registry}`,
|
||||
method: "delete",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<void>>(
|
||||
"DELETE",
|
||||
`hassio/docker/registries/${registry}`
|
||||
);
|
||||
};
|
||||
|
||||
+35
-12
@@ -1,4 +1,7 @@
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassioResponse } from "./common";
|
||||
import { hassioApiResultExtractor } from "./common";
|
||||
|
||||
export interface HassioHardwareAudioDevice {
|
||||
device?: string | null;
|
||||
@@ -27,18 +30,38 @@ export interface HassioHardwareInfo {
|
||||
|
||||
export const fetchHassioHardwareAudio = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<HassioHardwareAudioList> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/hardware/audio`,
|
||||
method: "get",
|
||||
});
|
||||
): Promise<HassioHardwareAudioList> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/hardware/audio`,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<HassioHardwareAudioList>>(
|
||||
"GET",
|
||||
"hassio/hardware/audio"
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchHassioHardwareInfo = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<HassioHardwareInfo> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/hardware/info`,
|
||||
method: "get",
|
||||
});
|
||||
): Promise<HassioHardwareInfo> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/hardware/info`,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<HassioHardwareInfo>>(
|
||||
"GET",
|
||||
"hassio/hardware/info"
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
+143
-70
@@ -1,4 +1,7 @@
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassioResponse } from "./common";
|
||||
import { hassioApiResultExtractor } from "./common";
|
||||
|
||||
export interface HassioHostInfo {
|
||||
agent_version: string;
|
||||
@@ -51,86 +54,156 @@ export interface HostDisksUsage {
|
||||
|
||||
export const fetchHassioHostInfo = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<HassioHostInfo> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/info",
|
||||
method: "get",
|
||||
});
|
||||
): Promise<HassioHostInfo> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/info",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
const response = await hass.callApi<HassioResponse<HassioHostInfo>>(
|
||||
"GET",
|
||||
"hassio/host/info"
|
||||
);
|
||||
return hassioApiResultExtractor(response);
|
||||
};
|
||||
|
||||
export const fetchHassioHassOsInfo = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<HassioHassOSInfo> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/info",
|
||||
method: "get",
|
||||
});
|
||||
): Promise<HassioHassOSInfo> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/info",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
export const rebootHost = async (hass: HomeAssistant) =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/reboot",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<HassioHassOSInfo>>(
|
||||
"GET",
|
||||
"hassio/os/info"
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const shutdownHost = async (hass: HomeAssistant) =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/shutdown",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
export const rebootHost = async (hass: HomeAssistant) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/reboot",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
}
|
||||
|
||||
export const updateOS = async (hass: HomeAssistant) =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/update",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
return hass.callApi<HassioResponse<void>>("POST", "hassio/host/reboot");
|
||||
};
|
||||
|
||||
export const configSyncOS = async (hass: HomeAssistant) =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/config/sync",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
export const shutdownHost = async (hass: HomeAssistant) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/shutdown",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
}
|
||||
|
||||
export const changeHostOptions = async (hass: HomeAssistant, options: any) =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/options",
|
||||
method: "post",
|
||||
data: options,
|
||||
});
|
||||
return hass.callApi<HassioResponse<void>>("POST", "hassio/host/shutdown");
|
||||
};
|
||||
|
||||
export const moveDatadisk = async (hass: HomeAssistant, device: string) =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/datadisk/move",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
data: { device },
|
||||
});
|
||||
export const updateOS = async (hass: HomeAssistant) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/update",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
}
|
||||
|
||||
return hass.callApi<HassioResponse<void>>("POST", "hassio/os/update");
|
||||
};
|
||||
|
||||
export const configSyncOS = async (hass: HomeAssistant) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/config/sync",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
}
|
||||
|
||||
return hass.callApi<HassioResponse<void>>("POST", "hassio/os/config/sync");
|
||||
};
|
||||
|
||||
export const changeHostOptions = async (hass: HomeAssistant, options: any) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/options",
|
||||
method: "post",
|
||||
data: options,
|
||||
});
|
||||
}
|
||||
|
||||
return hass.callApi<HassioResponse<void>>(
|
||||
"POST",
|
||||
"hassio/host/options",
|
||||
options
|
||||
);
|
||||
};
|
||||
|
||||
export const moveDatadisk = async (hass: HomeAssistant, device: string) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/datadisk/move",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
data: { device },
|
||||
});
|
||||
}
|
||||
|
||||
return hass.callApi<HassioResponse<void>>("POST", "hassio/os/datadisk/move");
|
||||
};
|
||||
|
||||
export const listDatadisks = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<DatadiskList> =>
|
||||
hass.callWS<DatadiskList>({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/datadisk/list",
|
||||
method: "get",
|
||||
timeout: null,
|
||||
});
|
||||
): Promise<DatadiskList> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS<DatadiskList>({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/datadisk/list",
|
||||
method: "get",
|
||||
timeout: null,
|
||||
});
|
||||
}
|
||||
|
||||
export const fetchHostDisksUsage = async (hass: HomeAssistant) =>
|
||||
hass.callWS<HostDisksUsage>({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/disks/default/usage",
|
||||
method: "get",
|
||||
timeout: 3600, // seconds. This can take a while
|
||||
params: { max_depth: 3 },
|
||||
});
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<DatadiskList>>("GET", "/os/datadisk/list")
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchHostDisksUsage = async (hass: HomeAssistant) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS<HostDisksUsage>({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/disks/default/usage",
|
||||
method: "get",
|
||||
timeout: 3600, // seconds. This can take a while
|
||||
params: { max_depth: 3 },
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<HostDisksUsage>>(
|
||||
"GET",
|
||||
"hassio/host/disks/default/usage"
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
+33
-14
@@ -1,6 +1,9 @@
|
||||
import { getCollection, type Connection } from "home-assistant-js-websocket";
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { supervisorApiWsRequest } from "../supervisor/supervisor";
|
||||
import type { HassioResponse } from "./common";
|
||||
import type { CreateSessionResponse } from "./supervisor";
|
||||
|
||||
function setIngressCookie(session: string): string {
|
||||
document.cookie = `ingress_session=${session};path=/api/hassio_ingress/;SameSite=Strict${
|
||||
@@ -10,14 +13,21 @@ function setIngressCookie(session: string): string {
|
||||
}
|
||||
|
||||
export const createHassioSession = async (
|
||||
hass: Pick<HomeAssistant, "callWS">
|
||||
hass: HomeAssistant
|
||||
): Promise<string> => {
|
||||
const wsResponse: { session: string } = await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/ingress/session",
|
||||
method: "post",
|
||||
});
|
||||
return setIngressCookie(wsResponse.session);
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
const wsResponse: { session: string } = await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/ingress/session",
|
||||
method: "post",
|
||||
});
|
||||
return setIngressCookie(wsResponse.session);
|
||||
}
|
||||
|
||||
const restResponse: { data: { session: string } } = await hass.callApi<
|
||||
HassioResponse<CreateSessionResponse>
|
||||
>("POST", "hassio/ingress/session");
|
||||
return setIngressCookie(restResponse.data.session);
|
||||
};
|
||||
|
||||
export interface IngressPanelInfo {
|
||||
@@ -40,13 +50,22 @@ export const getIngressPanelInfoCollection = (conn: Connection) =>
|
||||
);
|
||||
|
||||
export const validateHassioSession = async (
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
hass: HomeAssistant,
|
||||
session: string
|
||||
): Promise<void> => {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/ingress/validate_session",
|
||||
method: "post",
|
||||
data: { session },
|
||||
});
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/ingress/validate_session",
|
||||
method: "post",
|
||||
data: { session },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<void>>(
|
||||
"POST",
|
||||
"hassio/ingress/validate_session",
|
||||
{ session }
|
||||
);
|
||||
};
|
||||
|
||||
+52
-20
@@ -1,4 +1,7 @@
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassioResponse } from "./common";
|
||||
import { hassioApiResultExtractor } from "./common";
|
||||
|
||||
interface IpConfiguration {
|
||||
address: string[];
|
||||
@@ -52,37 +55,66 @@ export interface NetworkInfo {
|
||||
|
||||
export const fetchNetworkInfo = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<NetworkInfo> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/network/info",
|
||||
method: "get",
|
||||
});
|
||||
): Promise<NetworkInfo> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/network/info",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<NetworkInfo>>(
|
||||
"GET",
|
||||
"hassio/network/info"
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const updateNetworkInterface = async (
|
||||
hass: HomeAssistant,
|
||||
network_interface: string,
|
||||
options: Partial<NetworkInterface>
|
||||
) => {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/network/interface/${network_interface}/update`,
|
||||
method: "post",
|
||||
data: options,
|
||||
timeout: null,
|
||||
});
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/network/interface/${network_interface}/update`,
|
||||
method: "post",
|
||||
data: options,
|
||||
timeout: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<NetworkInfo>>(
|
||||
"POST",
|
||||
`hassio/network/interface/${network_interface}/update`,
|
||||
options
|
||||
);
|
||||
};
|
||||
|
||||
export const accesspointScan = async (
|
||||
hass: HomeAssistant,
|
||||
network_interface: string
|
||||
): Promise<AccessPoints> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/network/interface/${network_interface}/accesspoints`,
|
||||
method: "get",
|
||||
timeout: null,
|
||||
});
|
||||
): Promise<AccessPoints> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/network/interface/${network_interface}/accesspoints`,
|
||||
method: "get",
|
||||
timeout: null,
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<AccessPoints>>(
|
||||
"GET",
|
||||
`hassio/network/interface/${network_interface}/accesspoints`
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const parseAddress = (address: string) => {
|
||||
const [ip, cidr] = address.split("/");
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant, TranslationDict } from "../../types";
|
||||
import type { HassioResponse } from "./common";
|
||||
import { hassioApiResultExtractor } from "./common";
|
||||
|
||||
export interface HassioResolution {
|
||||
unsupported: (keyof TranslationDict["ui"]["dialogs"]["unsupported"]["reasons"])[];
|
||||
@@ -9,9 +12,19 @@ export interface HassioResolution {
|
||||
|
||||
export const fetchHassioResolution = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<HassioResolution> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/resolution/info",
|
||||
method: "get",
|
||||
});
|
||||
): Promise<HassioResolution> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/resolution/info",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<HassioResolution>>(
|
||||
"GET",
|
||||
"hassio/resolution/info"
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant, PanelInfo } from "../../types";
|
||||
import type { SupervisorArch } from "../supervisor/supervisor";
|
||||
import type { HassioResponse } from "./common";
|
||||
import { hassioApiResultExtractor } from "./common";
|
||||
|
||||
export interface HassioHomeAssistantInfo {
|
||||
arch: SupervisorArch;
|
||||
@@ -75,6 +77,10 @@ export type HassioPanelInfo = PanelInfo<
|
||||
}
|
||||
>;
|
||||
|
||||
export interface CreateSessionResponse {
|
||||
session: string;
|
||||
}
|
||||
|
||||
export interface SupervisorOptions {
|
||||
channel?: "beta" | "dev" | "stable";
|
||||
diagnostics?: boolean;
|
||||
@@ -82,57 +88,99 @@ export interface SupervisorOptions {
|
||||
}
|
||||
|
||||
export const reloadSupervisor = async (hass: HomeAssistant) => {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/reload",
|
||||
method: "post",
|
||||
});
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/reload",
|
||||
method: "post",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<void>>("POST", `hassio/supervisor/reload`);
|
||||
};
|
||||
|
||||
export const restartSupervisor = async (hass: HomeAssistant) => {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/restart",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/restart",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<void>>("POST", `hassio/supervisor/restart`);
|
||||
};
|
||||
|
||||
export const updateSupervisor = async (hass: HomeAssistant) => {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/update",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/update",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<void>>("POST", `hassio/supervisor/update`);
|
||||
};
|
||||
|
||||
export const fetchHassioHomeAssistantInfo = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<HassioHomeAssistantInfo> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/core/info",
|
||||
method: "get",
|
||||
});
|
||||
): Promise<HassioHomeAssistantInfo> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/core/info",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<HassioHomeAssistantInfo>>(
|
||||
"GET",
|
||||
"hassio/core/info"
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchHassioSupervisorInfo = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<HassioSupervisorInfo> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/info",
|
||||
method: "get",
|
||||
});
|
||||
): Promise<HassioSupervisorInfo> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/info",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<HassioSupervisorInfo>>(
|
||||
"GET",
|
||||
"hassio/supervisor/info"
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchHassioInfo = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<HassioInfo> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/info",
|
||||
method: "get",
|
||||
});
|
||||
): Promise<HassioInfo> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/info",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<HassioInfo>>("GET", "hassio/info")
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchHassioBoots = async (hass: HomeAssistant) =>
|
||||
hass.callApi<HassioResponse<HassioBoots>>("GET", `hassio/host/logs/boots`);
|
||||
@@ -215,12 +263,21 @@ export const setSupervisorOption = async (
|
||||
hass: HomeAssistant,
|
||||
data: SupervisorOptions
|
||||
) => {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/options",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/options",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<void>>(
|
||||
"POST",
|
||||
"hassio/supervisor/options",
|
||||
data
|
||||
);
|
||||
};
|
||||
|
||||
export const coreLatestLogsUrl = "/api/hassio/core/logs/latest";
|
||||
|
||||
@@ -21,6 +21,21 @@ export interface HttpConfigState {
|
||||
revert_at: string | null;
|
||||
}
|
||||
|
||||
export const HTTP_CONFIG_FIELDS: (keyof HttpConfig)[] = [
|
||||
"server_port",
|
||||
"server_host",
|
||||
"ssl_certificate",
|
||||
"ssl_key",
|
||||
"ssl_peer_certificate",
|
||||
"ssl_profile",
|
||||
"cors_allowed_origins",
|
||||
"use_x_forwarded_for",
|
||||
"trusted_proxies",
|
||||
"use_x_frame_options",
|
||||
"ip_ban_enabled",
|
||||
"login_attempts_threshold",
|
||||
];
|
||||
|
||||
export interface SaveHttpConfigResult {
|
||||
restart: boolean;
|
||||
}
|
||||
|
||||
@@ -7,11 +7,6 @@ export interface CustomPanelConfig {
|
||||
js_url?: string;
|
||||
module_url?: string;
|
||||
html_url?: string;
|
||||
// When true, the panel takes care of the safe-area insets itself (e.g. it
|
||||
// consumes the `--safe-area-inset-*` variables or draws into the safe area on
|
||||
// purpose). Home Assistant then skips adding its own safe-area padding around
|
||||
// the panel to avoid doubling the insets.
|
||||
handle_safe_area?: boolean;
|
||||
}
|
||||
|
||||
export type CustomPanelInfo<T = Record<string, unknown>> = PanelInfo<
|
||||
|
||||
@@ -80,7 +80,6 @@ export type Selector =
|
||||
| TTSVoiceSelector
|
||||
| SerialPortSelector
|
||||
| UiActionSelector
|
||||
| UiClockDateFormatSelector
|
||||
| UiColorSelector
|
||||
| UiStateContentSelector
|
||||
| UiTimeFormatSelector
|
||||
@@ -531,6 +530,10 @@ export interface StringSelector {
|
||||
placeholder?: string;
|
||||
autocomplete?: string;
|
||||
multiple?: true;
|
||||
// Regular expression the value must match (HTML `pattern`); with `multiple`
|
||||
// every entry is validated. `validation_message` is shown when it fails.
|
||||
pattern?: string;
|
||||
validation_message?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -578,10 +581,6 @@ export interface UiActionSelector {
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface UiClockDateFormatSelector {
|
||||
ui_clock_date_format: {} | null;
|
||||
}
|
||||
|
||||
export interface UiColorExtraOption {
|
||||
value: string;
|
||||
label: string;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassioResponse } from "../hassio/common";
|
||||
import { hassioApiResultExtractor } from "../hassio/common";
|
||||
|
||||
export interface SupervisorApiCallOptions {
|
||||
method?: "get" | "post" | "delete";
|
||||
@@ -10,11 +13,23 @@ export const supervisorApiCall = async <T>(
|
||||
hass: HomeAssistant,
|
||||
endpoint: string,
|
||||
options?: SupervisorApiCallOptions
|
||||
): Promise<T> =>
|
||||
hass.callWS<T>({
|
||||
type: "supervisor/api",
|
||||
endpoint,
|
||||
method: options?.method || "get",
|
||||
timeout: options?.timeout ?? null,
|
||||
data: options?.data,
|
||||
});
|
||||
): Promise<T> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
// Websockets was added in 2021.2.4
|
||||
return hass.callWS<T>({
|
||||
type: "supervisor/api",
|
||||
endpoint,
|
||||
method: options?.method || "get",
|
||||
timeout: options?.timeout ?? null,
|
||||
data: options?.data,
|
||||
});
|
||||
}
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<T>>(
|
||||
// @ts-ignore
|
||||
(options.method || "get").toUpperCase(),
|
||||
`hassio${endpoint}`,
|
||||
options?.data
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassioResponse } from "../hassio/common";
|
||||
|
||||
export const restartCore = async (hass: HomeAssistant) => {
|
||||
await hass.callService("homeassistant", "restart");
|
||||
};
|
||||
|
||||
export const updateCore = async (hass: HomeAssistant, backup: boolean) => {
|
||||
await hass.callWS({
|
||||
type: "hassio/update/core",
|
||||
backup: backup,
|
||||
if (atLeastVersion(hass.config.version, 2025, 2, 0)) {
|
||||
await hass.callWS({
|
||||
type: "hassio/update/core",
|
||||
backup: backup,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/core/update",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
data: { backup },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<void>>("POST", "hassio/core/update", {
|
||||
backup,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,34 +1,27 @@
|
||||
import { mdiArrowRight } from "@mdi/js";
|
||||
import { ERR_CONNECTION_LOST } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { formatNumericDuration } from "../../common/datetime/format_duration";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import "../../components/ha-alert";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-dialog-footer";
|
||||
import "../../components/ha-dialog";
|
||||
import "../../components/ha-svg-icon";
|
||||
import type { HttpConfig } from "../../data/http";
|
||||
import { promoteHttpConfig, saveHttpConfig } from "../../data/http";
|
||||
import {
|
||||
HTTP_CONFIG_FIELDS,
|
||||
promoteHttpConfig,
|
||||
saveHttpConfig,
|
||||
} from "../../data/http";
|
||||
import { haStyleDialog } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassDialog } from "../make-dialog-manager";
|
||||
import type { HttpPendingConfigDialogParams } from "./show-dialog-http-pending-config";
|
||||
|
||||
const HTTP_FIELDS: (keyof HttpConfig)[] = [
|
||||
"server_port",
|
||||
"server_host",
|
||||
"ssl_certificate",
|
||||
"ssl_key",
|
||||
"ssl_peer_certificate",
|
||||
"ssl_profile",
|
||||
"cors_allowed_origins",
|
||||
"use_x_forwarded_for",
|
||||
"trusted_proxies",
|
||||
"use_x_frame_options",
|
||||
"ip_ban_enabled",
|
||||
"login_attempts_threshold",
|
||||
];
|
||||
|
||||
@customElement("dialog-http-pending-config")
|
||||
export class DialogHttpPendingConfig
|
||||
extends LitElement
|
||||
@@ -57,6 +50,10 @@ export class DialogHttpPendingConfig
|
||||
this._error = undefined;
|
||||
this._reverted = false;
|
||||
this._startCountdown();
|
||||
// The field labels live in the config panel fragment, which is not loaded
|
||||
// yet when this dialog pops up on startup. Load it so the changed-field
|
||||
// names resolve; the dialog re-renders once hass updates.
|
||||
this.hass.loadFragmentTranslation("config");
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
@@ -114,17 +111,44 @@ export class DialogHttpPendingConfig
|
||||
return [];
|
||||
}
|
||||
const { stable, pending } = this._params.state;
|
||||
return HTTP_FIELDS.filter(
|
||||
return HTTP_CONFIG_FIELDS.filter(
|
||||
(key) => JSON.stringify(stable[key]) !== JSON.stringify(pending[key])
|
||||
);
|
||||
}
|
||||
|
||||
private _formatValue(key: keyof HttpConfig, value: unknown): string {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return this.hass.localize("ui.dialogs.http_pending_config.not_set");
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return this.hass.localize(value ? "ui.common.yes" : "ui.common.no");
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.length
|
||||
? value.join(", ")
|
||||
: this.hass.localize("ui.dialogs.http_pending_config.not_set");
|
||||
}
|
||||
if (key === "ssl_profile") {
|
||||
return (
|
||||
this.hass.localize(
|
||||
`ui.panel.config.network.http.ssl_profile_${value}` as any
|
||||
) || String(value)
|
||||
);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const changes = this._changedFields;
|
||||
const { stable, pending } = this._params.state;
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
@@ -187,9 +211,25 @@ export class DialogHttpPendingConfig
|
||||
${changes.map(
|
||||
(key) => html`
|
||||
<li>
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.network.http.fields.${key}` as any
|
||||
)}
|
||||
<span class="field">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.network.http.fields.${key}` as any
|
||||
)}
|
||||
</span>
|
||||
<span class="values">
|
||||
<span class="old"
|
||||
>${this._formatValue(key, stable[key])}</span
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${mdiArrowRight}
|
||||
style=${styleMap({
|
||||
transform: rtl ? "scaleX(-1)" : "",
|
||||
})}
|
||||
></ha-svg-icon>
|
||||
<span class="new"
|
||||
>${this._formatValue(key, pending![key])}</span
|
||||
>
|
||||
</span>
|
||||
</li>
|
||||
`
|
||||
)}
|
||||
@@ -320,15 +360,37 @@ export class DialogHttpPendingConfig
|
||||
margin-bottom: var(--ha-space-2);
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0 0 var(--ha-space-4) 0;
|
||||
padding-left: var(--ha-space-6);
|
||||
padding-inline-start: var(--ha-space-6);
|
||||
padding-inline-end: initial;
|
||||
color: var(--secondary-text-color);
|
||||
padding: 0;
|
||||
}
|
||||
li {
|
||||
padding: var(--ha-space-2) 0;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.field {
|
||||
display: block;
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
margin-bottom: var(--ha-space-1);
|
||||
}
|
||||
.values {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ha-space-2);
|
||||
color: var(--secondary-text-color);
|
||||
word-break: break-word;
|
||||
}
|
||||
.values .new {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.values ha-svg-icon {
|
||||
--mdc-icon-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-top: var(--ha-space-4);
|
||||
|
||||
@@ -33,12 +33,7 @@ import {
|
||||
TimeZone,
|
||||
} from "../data/translation";
|
||||
import { translationMetadata } from "../resources/translations-metadata";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
Resources,
|
||||
ThemeSettings,
|
||||
ValuePart,
|
||||
} from "../types";
|
||||
import type { HomeAssistant, Resources, ValuePart } from "../types";
|
||||
import { getLocalLanguage, getTranslation } from "../util/common-translation";
|
||||
import { demoConfig } from "./demo_config";
|
||||
import { demoPanels } from "./demo_panels";
|
||||
@@ -88,10 +83,7 @@ export interface MockHomeAssistant extends HomeAssistant {
|
||||
loader: () => Promise<unknown>
|
||||
);
|
||||
mockEvent(event);
|
||||
mockTheme(
|
||||
theme: Record<string, string> | null,
|
||||
selectedTheme?: ThemeSettings
|
||||
);
|
||||
mockTheme(theme: Record<string, string> | null);
|
||||
formatEntityState(stateObj: HassEntity, state?: string): string;
|
||||
formatEntityStateToParts(stateObj: HassEntity, state?: string): ValuePart[];
|
||||
formatEntityAttributeValue(
|
||||
@@ -511,35 +503,25 @@ export const provideHass = (
|
||||
},
|
||||
mockAPI,
|
||||
mockEvent(event) {
|
||||
(eventListeners[event] || []).forEach((fn) => {
|
||||
fn(event);
|
||||
});
|
||||
(eventListeners[event] || []).forEach((fn) => fn(event));
|
||||
},
|
||||
mockTheme(theme, selectedTheme) {
|
||||
mockTheme(theme) {
|
||||
invalidateThemeCache();
|
||||
selectedTheme ??= {
|
||||
theme: theme ? "fake-data" : "default",
|
||||
dark: false,
|
||||
};
|
||||
const themeName = selectedTheme.theme;
|
||||
const darkMode =
|
||||
selectedTheme.dark ??
|
||||
matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
hass().updateHass({
|
||||
selectedTheme,
|
||||
selectedTheme: { theme: theme ? "mock" : "default", dark: false },
|
||||
themes: {
|
||||
...hass().themes,
|
||||
darkMode,
|
||||
theme: themeName,
|
||||
themes: theme ? { [themeName]: theme as any } : {},
|
||||
themes: {
|
||||
mock: theme as any,
|
||||
},
|
||||
},
|
||||
});
|
||||
const { themes } = hass();
|
||||
const { themes, selectedTheme } = hass();
|
||||
applyThemesOnElement(
|
||||
document.documentElement,
|
||||
themes,
|
||||
themeName,
|
||||
{ ...selectedTheme, dark: darkMode },
|
||||
selectedTheme!.theme,
|
||||
{ dark: false },
|
||||
true
|
||||
);
|
||||
},
|
||||
|
||||
@@ -53,11 +53,6 @@ class HaPanelApp extends LitElement {
|
||||
|
||||
@state() private _iframeLoaded = false;
|
||||
|
||||
// Set when the addon signals (via subscribe-properties) that it handles the
|
||||
// safe-area insets itself. We then stop padding the iframe and forward the
|
||||
// inset values so the addon can draw into the safe area.
|
||||
@state() private _handleSafeArea = false;
|
||||
|
||||
private _enabledKioskMode = false;
|
||||
|
||||
private _sessionKeepAlive?: number;
|
||||
@@ -93,13 +88,11 @@ class HaPanelApp extends LitElement {
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("message", this._handleIframeMessage);
|
||||
window.addEventListener("resize", this._handleResize);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("message", this._handleIframeMessage);
|
||||
window.removeEventListener("resize", this._handleResize);
|
||||
|
||||
if (this._sessionKeepAlive) {
|
||||
clearInterval(this._sessionKeepAlive);
|
||||
@@ -142,7 +135,6 @@ class HaPanelApp extends LitElement {
|
||||
class=${classMap({
|
||||
loaded: this._iframeLoaded,
|
||||
"kiosk-mode": this._kioskMode,
|
||||
"handle-safe-area": this._handleSafeArea,
|
||||
})}
|
||||
title=${this._addon.name}
|
||||
src=${this._addon.ingress_url!}
|
||||
@@ -187,7 +179,6 @@ class HaPanelApp extends LitElement {
|
||||
this._enabledKioskMode = false;
|
||||
}
|
||||
this._iframeSubscribeUpdates = false;
|
||||
this._handleSafeArea = false;
|
||||
this._autoRetryUntil = undefined;
|
||||
this._fetchData(addon);
|
||||
}
|
||||
@@ -425,9 +416,6 @@ class HaPanelApp extends LitElement {
|
||||
|
||||
case "home-assistant/subscribe-properties":
|
||||
this._iframeSubscribeUpdates = true;
|
||||
// An addon can opt out of the container padding and take care of the
|
||||
// safe area itself; we then forward the inset values below.
|
||||
this._handleSafeArea = !!data.handleSafeArea;
|
||||
this._sendPropertiesToIframe();
|
||||
if (data.kioskMode && !this.hass.kioskMode) {
|
||||
this._enabledKioskMode = true;
|
||||
@@ -437,7 +425,6 @@ class HaPanelApp extends LitElement {
|
||||
|
||||
case "home-assistant/unsubscribe-properties":
|
||||
this._iframeSubscribeUpdates = false;
|
||||
this._handleSafeArea = false;
|
||||
if (this._enabledKioskMode) {
|
||||
fireEvent(window, "hass-kiosk-mode", { enable: false });
|
||||
this._enabledKioskMode = false;
|
||||
@@ -446,38 +433,16 @@ class HaPanelApp extends LitElement {
|
||||
}
|
||||
};
|
||||
|
||||
// Safe-area insets can change on orientation change; keep a subscribing
|
||||
// addon in sync.
|
||||
private _handleResize = () => {
|
||||
if (this._iframeSubscribeUpdates) {
|
||||
this._sendPropertiesToIframe();
|
||||
}
|
||||
};
|
||||
|
||||
private _sendPropertiesToIframe() {
|
||||
if (!this._iframeRef.value?.contentWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const styles = getComputedStyle(this);
|
||||
this._iframeRef.value.contentWindow.postMessage(
|
||||
{
|
||||
type: "home-assistant/properties",
|
||||
narrow: this.narrow,
|
||||
route: this._computeRouteTail(this.route),
|
||||
// Resolved insets so an addon that handles the safe area itself can
|
||||
// apply them. Vertical uses the raw insets, horizontal the content
|
||||
// variables (the docked sidebar already absorbs its side).
|
||||
safeAreaInsets: {
|
||||
top: styles.getPropertyValue("--safe-area-inset-top").trim(),
|
||||
right:
|
||||
styles.getPropertyValue("--safe-area-content-inset-right").trim() ||
|
||||
styles.getPropertyValue("--safe-area-inset-right").trim(),
|
||||
bottom: styles.getPropertyValue("--safe-area-inset-bottom").trim(),
|
||||
left:
|
||||
styles.getPropertyValue("--safe-area-content-inset-left").trim() ||
|
||||
styles.getPropertyValue("--safe-area-inset-left").trim(),
|
||||
},
|
||||
},
|
||||
"*"
|
||||
);
|
||||
@@ -497,38 +462,30 @@ class HaPanelApp extends LitElement {
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* Keep the addon iframe clear of the device safe areas. CSS variables don't
|
||||
cross the iframe boundary, so this padding on the iframe element is the
|
||||
only way to inset the embedded document. Vertical uses the raw insets;
|
||||
horizontal uses the content variables, since the docked sidebar already
|
||||
absorbs the inset on its side (avoids doubling it). */
|
||||
iframe {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background-color: var(--primary-background-color);
|
||||
opacity: 0;
|
||||
transition: opacity var(--ha-animation-duration-normal) ease;
|
||||
padding: var(--safe-area-inset-top)
|
||||
var(--safe-area-content-inset-right, var(--safe-area-inset-right))
|
||||
var(--safe-area-inset-bottom)
|
||||
var(--safe-area-content-inset-left, var(--safe-area-inset-left));
|
||||
}
|
||||
|
||||
iframe.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* The addon takes care of the safe area itself (it receives the insets via
|
||||
postMessage), so drop the container padding to let it draw full-bleed. */
|
||||
iframe.handle-safe-area {
|
||||
padding: 0;
|
||||
.header + iframe {
|
||||
height: calc(100% - 40px);
|
||||
}
|
||||
|
||||
/* When the header is shown it already covers the top inset. */
|
||||
.header + iframe {
|
||||
:host([narrow]) iframe {
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
height: calc(100% - var(--safe-area-inset-top, 0px));
|
||||
}
|
||||
|
||||
:host([narrow]) .header + iframe {
|
||||
padding-top: 0;
|
||||
height: calc(100% - 40px - var(--safe-area-inset-top, 0px));
|
||||
}
|
||||
@@ -537,17 +494,8 @@ class HaPanelApp extends LitElement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: var(--ha-font-size-l);
|
||||
height: calc(40px + var(--safe-area-inset-top, 0px));
|
||||
padding: var(--safe-area-inset-top)
|
||||
calc(
|
||||
16px +
|
||||
var(--safe-area-content-inset-right, var(--safe-area-inset-right))
|
||||
)
|
||||
0
|
||||
calc(
|
||||
16px +
|
||||
var(--safe-area-content-inset-left, var(--safe-area-inset-left))
|
||||
);
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
pointer-events: none;
|
||||
background-color: var(--app-header-background-color);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
@@ -557,6 +505,11 @@ class HaPanelApp extends LitElement {
|
||||
--mdc-icon-size: 20px;
|
||||
}
|
||||
|
||||
:host([narrow]) .header {
|
||||
height: calc(40px + var(--safe-area-inset-top, 0px));
|
||||
padding-top: var(--safe-area-inset-top, 0);
|
||||
}
|
||||
|
||||
.main-title {
|
||||
margin-inline-start: var(--ha-space-6);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
|
||||
@@ -386,9 +386,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
|
||||
this._dirtyState?.setState(
|
||||
{
|
||||
name: this._name || null,
|
||||
icon: this._icon || null,
|
||||
entityId: this._entityId,
|
||||
name: this._name.trim() || null,
|
||||
icon: this._icon.trim() || null,
|
||||
entityId: this._entityId.trim(),
|
||||
areaId: this._areaId ?? null,
|
||||
labels: this._labels ?? [],
|
||||
deviceClass: this._deviceClass,
|
||||
|
||||
@@ -3,6 +3,10 @@ import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
IP_ADDRESS_OR_NETWORK_PATTERN,
|
||||
IP_ADDRESS_PATTERN,
|
||||
} from "../../../common/string/is_ip_address";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
@@ -10,7 +14,11 @@ import "../../../components/ha-card";
|
||||
import "../../../components/ha-form/ha-form";
|
||||
import type { HaForm } from "../../../components/ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../../../components/ha-form/types";
|
||||
import { fetchHttpConfig, saveHttpConfig } from "../../../data/http";
|
||||
import {
|
||||
fetchHttpConfig,
|
||||
HTTP_CONFIG_FIELDS,
|
||||
saveHttpConfig,
|
||||
} from "../../../data/http";
|
||||
import type { HttpConfig } from "../../../data/http";
|
||||
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
@@ -24,10 +32,6 @@ const SCHEMA = memoizeOne(
|
||||
required: true,
|
||||
selector: { number: { min: 1, max: 65535, mode: "box" } },
|
||||
},
|
||||
{
|
||||
name: "server_host",
|
||||
selector: { text: { multiple: true } },
|
||||
},
|
||||
{
|
||||
name: "ssl",
|
||||
type: "expandable",
|
||||
@@ -81,7 +85,15 @@ const SCHEMA = memoizeOne(
|
||||
},
|
||||
{
|
||||
name: "trusted_proxies",
|
||||
selector: { text: { multiple: true } },
|
||||
selector: {
|
||||
text: {
|
||||
multiple: true,
|
||||
pattern: IP_ADDRESS_OR_NETWORK_PATTERN,
|
||||
validation_message: localize(
|
||||
"ui.panel.config.network.http.invalid_network"
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -108,6 +120,18 @@ const SCHEMA = memoizeOne(
|
||||
flatten: true,
|
||||
title: localize("ui.panel.config.network.http.sections.advanced"),
|
||||
schema: [
|
||||
{
|
||||
name: "server_host",
|
||||
selector: {
|
||||
text: {
|
||||
multiple: true,
|
||||
pattern: IP_ADDRESS_PATTERN,
|
||||
validation_message: localize(
|
||||
"ui.panel.config.network.http.invalid_host"
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cors_allowed_origins",
|
||||
selector: { text: { multiple: true } },
|
||||
@@ -165,6 +189,9 @@ class HaConfigHttpForm extends LitElement {
|
||||
|
||||
const schema = SCHEMA(this.hass.localize);
|
||||
|
||||
const portChanged =
|
||||
!!this._stable && this._config?.server_port !== this._stable.server_port;
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
outlined
|
||||
@@ -174,6 +201,17 @@ class HaConfigHttpForm extends LitElement {
|
||||
<p class="description">
|
||||
${this.hass.localize("ui.panel.config.network.http.description")}
|
||||
</p>
|
||||
${
|
||||
portChanged
|
||||
? html`
|
||||
<ha-alert alert-type="warning">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.network.http.port_warning"
|
||||
)}
|
||||
</ha-alert>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
@@ -318,15 +356,7 @@ class HaConfigHttpForm extends LitElement {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// voluptuous formats errors as "<message> @ data['<field>']".
|
||||
// If a field is identified, mark it inline; otherwise show a card-level
|
||||
// alert.
|
||||
const fieldMatch = err.message?.match(/\bdata\['([^']+)'\]/);
|
||||
if (fieldMatch) {
|
||||
this._fieldErrors = { [fieldMatch[1]]: err.message };
|
||||
} else {
|
||||
this._error = err.message;
|
||||
}
|
||||
this._handleSaveError(err);
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
@@ -340,6 +370,34 @@ class HaConfigHttpForm extends LitElement {
|
||||
target?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
|
||||
private _handleSaveError(err: any): void {
|
||||
const rawMessage =
|
||||
(typeof err === "string" ? err : err?.message) ||
|
||||
this.hass.localize("ui.panel.config.network.http.save_error");
|
||||
// Voluptuous formats validation errors as
|
||||
// "<reason> @ data['config']['<field>'][<index>]. Got '<value>'"
|
||||
// Strip the internal data path for display and pick the deepest known
|
||||
// field name so it can also be flagged inline.
|
||||
const message =
|
||||
rawMessage.replace(/\s*@\s*data(\['[^']*'\]|\[\d+\])+/g, "").trim() ||
|
||||
rawMessage;
|
||||
const field = [...rawMessage.matchAll(/\['([^']+)'\]/g)]
|
||||
.map((match) => match[1])
|
||||
.reverse()
|
||||
.find((name) => HTTP_CONFIG_FIELDS.includes(name as keyof HttpConfig)) as
|
||||
keyof HttpConfig | undefined;
|
||||
|
||||
if (field) {
|
||||
// Show a card-level alert too — the field may sit in a collapsed section.
|
||||
this._error = `${this.hass.localize(
|
||||
`ui.panel.config.network.http.fields.${field}` as any
|
||||
)}: ${message}`;
|
||||
this._fieldErrors = { [field]: message };
|
||||
} else {
|
||||
this._error = message;
|
||||
}
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
|
||||
@@ -36,11 +36,6 @@ export class HaPanelCustom extends ReactiveElement {
|
||||
|
||||
private _wasDisconnected = false;
|
||||
|
||||
// Set for embedded-iframe panels that opt out of the container padding via
|
||||
// `handle_safe_area`; we then inject the resolved insets into the (same
|
||||
// origin) iframe document so the panel can consume `--safe-area-inset-*`.
|
||||
private _syncSafeArea = false;
|
||||
|
||||
protected createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
@@ -59,14 +54,10 @@ export class HaPanelCustom extends ReactiveElement {
|
||||
});
|
||||
this._setProperties = setProperties;
|
||||
this.querySelector("iframe")?.classList.add("loaded");
|
||||
// registerIframe also fires on the iframe's `pageshow`, so this re-applies
|
||||
// the insets after an internal reload.
|
||||
this._syncSafeAreaToIframe();
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("resize", this._handleResize);
|
||||
// Only rebuild when reattached after a real disconnect (the 5-minute
|
||||
// suspendWhenHidden timer in partial-panel-resolver). On first mount,
|
||||
// update() handles creation via the panel-changed branch, so calling
|
||||
@@ -79,51 +70,10 @@ export class HaPanelCustom extends ReactiveElement {
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("resize", this._handleResize);
|
||||
this._wasDisconnected = true;
|
||||
this._cleanupPanel();
|
||||
}
|
||||
|
||||
// Safe-area insets can change on orientation change; keep the embedded
|
||||
// document in sync.
|
||||
private _handleResize = () => {
|
||||
this._syncSafeAreaToIframe();
|
||||
};
|
||||
|
||||
private _syncSafeAreaToIframe() {
|
||||
if (!this._syncSafeArea) {
|
||||
return;
|
||||
}
|
||||
const root =
|
||||
this.querySelector("iframe")?.contentWindow?.document?.documentElement;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
// CSS variables don't cross the iframe boundary, so copy the resolved
|
||||
// values onto the (same-origin) iframe document. Vertical uses the raw
|
||||
// insets, horizontal the content variables (the sidebar already absorbs
|
||||
// its side).
|
||||
const styles = getComputedStyle(this);
|
||||
root.style.setProperty(
|
||||
"--safe-area-inset-top",
|
||||
styles.getPropertyValue("--safe-area-inset-top").trim()
|
||||
);
|
||||
root.style.setProperty(
|
||||
"--safe-area-inset-bottom",
|
||||
styles.getPropertyValue("--safe-area-inset-bottom").trim()
|
||||
);
|
||||
root.style.setProperty(
|
||||
"--safe-area-inset-left",
|
||||
styles.getPropertyValue("--safe-area-content-inset-left").trim() ||
|
||||
styles.getPropertyValue("--safe-area-inset-left").trim()
|
||||
);
|
||||
root.style.setProperty(
|
||||
"--safe-area-inset-right",
|
||||
styles.getPropertyValue("--safe-area-content-inset-right").trim() ||
|
||||
styles.getPropertyValue("--safe-area-inset-right").trim()
|
||||
);
|
||||
}
|
||||
|
||||
protected update(changedProps: PropertyValues<this>) {
|
||||
super.update(changedProps);
|
||||
if (changedProps.has("panel")) {
|
||||
@@ -151,7 +101,6 @@ export class HaPanelCustom extends ReactiveElement {
|
||||
private _cleanupPanel() {
|
||||
delete window.customPanel;
|
||||
this._setProperties = undefined;
|
||||
this._syncSafeArea = false;
|
||||
while (this.lastChild) {
|
||||
this.removeChild(this.lastChild);
|
||||
}
|
||||
@@ -163,30 +112,6 @@ export class HaPanelCustom extends ReactiveElement {
|
||||
const config = panel.config!._panel_custom;
|
||||
const panelUrl = getUrl(config);
|
||||
|
||||
// Keep the panel content clear of the device safe areas (status bar, home
|
||||
// indicator, notch). Panels rendered in light DOM inherit the
|
||||
// `--safe-area-inset-*` variables but most don't consume them, so we apply
|
||||
// the padding on the container as a safe baseline. The embedded-iframe
|
||||
// branch below applies the equivalent padding on the iframe instead. Panels
|
||||
// that manage the safe area themselves can opt out via `handle_safe_area`.
|
||||
const applySafeArea = !config.handle_safe_area;
|
||||
// For opted-out embedded-iframe panels we inject the insets into the iframe
|
||||
// document instead of padding it (see _syncSafeAreaToIframe).
|
||||
this._syncSafeArea = !applySafeArea && !!config.embed_iframe;
|
||||
if (applySafeArea && !config.embed_iframe) {
|
||||
this.style.display = "block";
|
||||
this.style.boxSizing = "border-box";
|
||||
// Vertical insets aren't absorbed by any chrome around the panel, so use
|
||||
// the raw insets. Horizontal uses the (physical) content variables, since
|
||||
// the sidebar already absorbs the inset on its side (avoids doubling it).
|
||||
this.style.paddingTop = "var(--safe-area-inset-top)";
|
||||
this.style.paddingBottom = "var(--safe-area-inset-bottom)";
|
||||
this.style.paddingLeft =
|
||||
"var(--safe-area-content-inset-left, var(--safe-area-inset-left))";
|
||||
this.style.paddingRight =
|
||||
"var(--safe-area-content-inset-right, var(--safe-area-inset-right))";
|
||||
}
|
||||
|
||||
const tempA = document.createElement("a");
|
||||
tempA.href = panelUrl.url;
|
||||
|
||||
@@ -243,26 +168,14 @@ export class HaPanelCustom extends ReactiveElement {
|
||||
|
||||
window.customPanel = this;
|
||||
const titleAttr = this.panel.title ? `title="${this.panel.title}"` : "";
|
||||
// Pad the iframe (not the host) with the safe-area insets so the embedded
|
||||
// document stays clear of the device safe areas. CSS variables don't cross
|
||||
// the iframe boundary, so this container padding is the baseline; panels
|
||||
// that handle it themselves opt out via `handle_safe_area`.
|
||||
const safeAreaPadding = applySafeArea
|
||||
? `padding: var(--safe-area-inset-top)
|
||||
var(--safe-area-content-inset-right, var(--safe-area-inset-right))
|
||||
var(--safe-area-inset-bottom)
|
||||
var(--safe-area-content-inset-left, var(--safe-area-inset-left));`
|
||||
: "";
|
||||
this.innerHTML = `
|
||||
<style>
|
||||
iframe {
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
display: block;
|
||||
${safeAreaPadding}
|
||||
background-color: var(--primary-background-color);
|
||||
opacity: 0;
|
||||
transition: opacity var(--ha-animation-duration-normal) ease;
|
||||
|
||||
@@ -128,13 +128,19 @@ class HaPanelHistory extends LitElement {
|
||||
<h1 class="page-title" slot="title">
|
||||
${this.hass.localize("panel.history")}
|
||||
</h1>
|
||||
<ha-icon-button
|
||||
slot="actionItems"
|
||||
@click=${this._removeAll}
|
||||
.disabled=${this._isLoading || !entitiesSelected}
|
||||
.path=${mdiFilterRemove}
|
||||
.label=${this.hass.localize("ui.panel.history.remove_all")}
|
||||
></ha-icon-button>
|
||||
${
|
||||
entitiesSelected
|
||||
? html`
|
||||
<ha-icon-button
|
||||
slot="actionItems"
|
||||
@click=${this._removeAll}
|
||||
.disabled=${this._isLoading}
|
||||
.path=${mdiFilterRemove}
|
||||
.label=${this.hass.localize("ui.panel.history.remove_all")}
|
||||
></ha-icon-button>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
<ha-dropdown slot="actionItems" @wa-select=${this._handleMenuAction}>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
|
||||
@@ -49,13 +49,10 @@ class HaPanelIframe extends LitElement {
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
/* Fill hass-subpage's content box, which already excludes the safe-area
|
||||
insets (see hass-subpage .content), instead of positioning absolutely
|
||||
and spilling into the bottom/side insets. */
|
||||
iframe {
|
||||
border: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
background-color: var(--primary-background-color);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mdiFilterRemove, mdiRefresh } from "@mdi/js";
|
||||
import { mdiRefresh } from "@mdi/js";
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
extractSearchParamsObject,
|
||||
removeSearchParam,
|
||||
} from "../../common/url/search-params";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import "../../components/date-picker/ha-date-range-picker";
|
||||
import "../../components/ha-icon-button";
|
||||
import "../../components/ha-target-picker";
|
||||
@@ -28,11 +27,6 @@ import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "./ha-logbook";
|
||||
|
||||
interface LogbookState {
|
||||
time: { range: [Date, Date] };
|
||||
targetPickerValue: HassServiceTarget;
|
||||
}
|
||||
|
||||
@customElement("ha-panel-logbook")
|
||||
export class HaPanelLogbook extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -60,7 +54,14 @@ export class HaPanelLogbook extends LitElement {
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
this._time = this._defaultState.time;
|
||||
|
||||
const start = new Date();
|
||||
start.setHours(start.getHours() - 1, 0, 0, 0);
|
||||
|
||||
const end = new Date();
|
||||
end.setHours(end.getHours() + 2, 0, 0, 0);
|
||||
|
||||
this._time = { range: [start, end] };
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -70,13 +71,6 @@ export class HaPanelLogbook extends LitElement {
|
||||
.backButton=${!!this._showBack}
|
||||
>
|
||||
<div slot="title">${this.hass.localize("panel.logbook")}</div>
|
||||
<ha-icon-button
|
||||
slot="actionItems"
|
||||
@click=${this._resetLogbook}
|
||||
.disabled=${this._isDefaultState()}
|
||||
.path=${mdiFilterRemove}
|
||||
.label=${this.hass.localize("ui.common.reset")}
|
||||
></ha-icon-button>
|
||||
<ha-icon-button
|
||||
slot="actionItems"
|
||||
@click=${this._refreshLogbook}
|
||||
@@ -236,34 +230,6 @@ export class HaPanelLogbook extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
private get _defaultState(): LogbookState {
|
||||
const start = new Date();
|
||||
start.setHours(start.getHours() - 1, 0, 0, 0);
|
||||
|
||||
const end = new Date();
|
||||
end.setHours(end.getHours() + 2, 0, 0, 0);
|
||||
|
||||
return {
|
||||
time: { range: [start, end] },
|
||||
targetPickerValue: {},
|
||||
};
|
||||
}
|
||||
|
||||
private _isDefaultState(): boolean {
|
||||
return deepEqual(
|
||||
{ time: this._time, targetPickerValue: this._targetPickerValue },
|
||||
this._defaultState
|
||||
);
|
||||
}
|
||||
|
||||
private _resetLogbook() {
|
||||
const defaultState = this._defaultState;
|
||||
this._time = defaultState.time;
|
||||
this._targetPickerValue = defaultState.targetPickerValue;
|
||||
this._storedTargetPickerValue = undefined;
|
||||
navigate("/logbook", { replace: true });
|
||||
}
|
||||
|
||||
private _refreshLogbook() {
|
||||
this.shadowRoot!.querySelector("ha-logbook")?.refresh();
|
||||
}
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { ClockCardConfig, ClockCardDatePart } from "../types";
|
||||
|
||||
type ClockCardSeparatorPart = Extract<
|
||||
ClockCardDatePart,
|
||||
"separator-dash" | "separator-slash" | "separator-dot" | "separator-new-line"
|
||||
>;
|
||||
|
||||
type ClockCardValuePart = Exclude<ClockCardDatePart, ClockCardSeparatorPart>;
|
||||
|
||||
/**
|
||||
* Normalized date configuration used by clock card renderers.
|
||||
*/
|
||||
interface ClockCardDateConfig {
|
||||
parts: ClockCardDatePart[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the locale and time zone for a clock card from `hass` and the
|
||||
* card's configuration. Applies the optional `time_format` override to the
|
||||
* locale and falls back to the user's preferred time zone.
|
||||
*/
|
||||
export const resolveClockCardLocale = (
|
||||
hass: HomeAssistant,
|
||||
config: Pick<ClockCardConfig, "time_format" | "time_zone">
|
||||
): { locale: HomeAssistant["locale"]; timeZone: string } => {
|
||||
const locale = config.time_format
|
||||
? { ...hass.locale, time_format: config.time_format }
|
||||
: hass.locale;
|
||||
|
||||
const timeZone =
|
||||
config.time_zone ||
|
||||
resolveTimeZone(locale.time_zone, hass.config?.time_zone);
|
||||
|
||||
return { locale, timeZone };
|
||||
};
|
||||
|
||||
/**
|
||||
* All selectable date tokens exposed by the clock card editor.
|
||||
*/
|
||||
export const CLOCK_CARD_DATE_PARTS: readonly ClockCardDatePart[] = [
|
||||
"weekday-short",
|
||||
"weekday-long",
|
||||
"day-numeric",
|
||||
"day-2-digit",
|
||||
"month-short",
|
||||
"month-long",
|
||||
"month-numeric",
|
||||
"month-2-digit",
|
||||
"year-2-digit",
|
||||
"year-numeric",
|
||||
"separator-dash",
|
||||
"separator-slash",
|
||||
"separator-dot",
|
||||
"separator-new-line",
|
||||
];
|
||||
|
||||
const DATE_PART_OPTIONS: Record<
|
||||
ClockCardValuePart,
|
||||
Pick<Intl.DateTimeFormatOptions, "weekday" | "day" | "month" | "year">
|
||||
> = {
|
||||
"weekday-short": { weekday: "short" },
|
||||
"weekday-long": { weekday: "long" },
|
||||
"day-numeric": { day: "numeric" },
|
||||
"day-2-digit": { day: "2-digit" },
|
||||
"month-short": { month: "short" },
|
||||
"month-long": { month: "long" },
|
||||
"month-numeric": { month: "numeric" },
|
||||
"month-2-digit": { month: "2-digit" },
|
||||
"year-2-digit": { year: "2-digit" },
|
||||
"year-numeric": { year: "numeric" },
|
||||
};
|
||||
|
||||
const DATE_SEPARATORS: Record<ClockCardSeparatorPart, string> = {
|
||||
"separator-dash": "-",
|
||||
"separator-slash": "/",
|
||||
"separator-dot": ".",
|
||||
"separator-new-line": "\n",
|
||||
};
|
||||
|
||||
const DATE_SEPARATOR_PARTS = new Set<ClockCardSeparatorPart>([
|
||||
"separator-dash",
|
||||
"separator-slash",
|
||||
"separator-dot",
|
||||
"separator-new-line",
|
||||
]);
|
||||
|
||||
const DATE_PART_FORMATTERS = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
const isClockCardDatePart = (value: string): value is ClockCardDatePart =>
|
||||
CLOCK_CARD_DATE_PARTS.includes(value as ClockCardDatePart);
|
||||
|
||||
const isDateSeparatorPart = (
|
||||
part: ClockCardDatePart
|
||||
): part is ClockCardSeparatorPart =>
|
||||
DATE_SEPARATOR_PARTS.has(part as ClockCardSeparatorPart);
|
||||
|
||||
/**
|
||||
* Returns a reusable formatter for a specific date token.
|
||||
*/
|
||||
const getDatePartFormatter = (
|
||||
part: ClockCardValuePart,
|
||||
language: string,
|
||||
timeZone?: string
|
||||
): Intl.DateTimeFormat => {
|
||||
const cacheKey = `${language}|${timeZone || ""}|${part}`;
|
||||
const cached = DATE_PART_FORMATTERS.get(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const formatter = new Intl.DateTimeFormat(language, {
|
||||
...DATE_PART_OPTIONS[part],
|
||||
...(timeZone ? { timeZone } : {}),
|
||||
});
|
||||
|
||||
DATE_PART_FORMATTERS.set(cacheKey, formatter);
|
||||
|
||||
return formatter;
|
||||
};
|
||||
|
||||
const formatDatePart = (
|
||||
part: ClockCardValuePart,
|
||||
date: Date,
|
||||
language: string,
|
||||
timeZone?: string
|
||||
) => getDatePartFormatter(part, language, timeZone).format(date);
|
||||
|
||||
/**
|
||||
* Applies a single date token to Intl.DateTimeFormat options.
|
||||
*/
|
||||
const applyDatePartOption = (
|
||||
options: Intl.DateTimeFormatOptions,
|
||||
part: ClockCardDatePart
|
||||
) => {
|
||||
if (isDateSeparatorPart(part)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const partOptions = DATE_PART_OPTIONS[part];
|
||||
|
||||
if (partOptions.weekday) {
|
||||
options.weekday = partOptions.weekday;
|
||||
}
|
||||
|
||||
if (partOptions.day) {
|
||||
options.day = partOptions.day;
|
||||
}
|
||||
|
||||
if (partOptions.month) {
|
||||
options.month = partOptions.month;
|
||||
}
|
||||
|
||||
if (partOptions.year) {
|
||||
options.year = partOptions.year;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitizes configured date tokens while preserving their literal order.
|
||||
*/
|
||||
const normalizeDateParts = (
|
||||
parts: ClockCardConfig["date_format"]
|
||||
): ClockCardDatePart[] =>
|
||||
parts?.filter((part): part is ClockCardDatePart =>
|
||||
isClockCardDatePart(part)
|
||||
) || [];
|
||||
|
||||
/**
|
||||
* Returns a normalized date config from a card configuration object.
|
||||
*/
|
||||
export const getClockCardDateConfig = (
|
||||
config?: Pick<ClockCardConfig, "date_format">
|
||||
): ClockCardDateConfig => ({
|
||||
parts: normalizeDateParts(config?.date_format),
|
||||
});
|
||||
|
||||
/**
|
||||
* Checks whether the clock configuration resolves to any visible date output.
|
||||
*/
|
||||
export const hasClockCardDate = (
|
||||
config?: Pick<ClockCardConfig, "date_format">
|
||||
): boolean => getClockCardDateConfig(config).parts.length > 0;
|
||||
|
||||
/**
|
||||
* Converts normalized date tokens into Intl.DateTimeFormat options.
|
||||
*
|
||||
* Separator tokens are ignored. If multiple tokens target the same Intl field,
|
||||
* the last one wins.
|
||||
*/
|
||||
export const getClockCardDateTimeFormatOptions = (
|
||||
dateConfig: ClockCardDateConfig
|
||||
): Intl.DateTimeFormatOptions => {
|
||||
const options: Intl.DateTimeFormatOptions = {};
|
||||
|
||||
dateConfig.parts.forEach((part) => {
|
||||
applyDatePartOption(options, part);
|
||||
});
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the final date string from literal date tokens.
|
||||
*
|
||||
* Value tokens are localized through Intl.DateTimeFormat. Separator tokens are
|
||||
* always rendered literally. A default space is only inserted between adjacent
|
||||
* value tokens.
|
||||
*/
|
||||
export const formatClockCardDate = (
|
||||
date: Date,
|
||||
dateConfig: ClockCardDateConfig,
|
||||
language: string,
|
||||
timeZone?: string
|
||||
): string => {
|
||||
let result = "";
|
||||
let previousRenderedPartWasValue = false;
|
||||
|
||||
dateConfig.parts.forEach((part) => {
|
||||
if (isDateSeparatorPart(part)) {
|
||||
result += DATE_SEPARATORS[part];
|
||||
previousRenderedPartWasValue = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const value = formatDatePart(part, date, language, timeZone);
|
||||
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousRenderedPartWasValue) {
|
||||
result += " ";
|
||||
}
|
||||
|
||||
result += value;
|
||||
previousRenderedPartWasValue = true;
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -2,17 +2,9 @@ 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 "../../../../components/ha-marquee-text";
|
||||
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { ClockCardConfig } from "../types";
|
||||
import {
|
||||
formatClockCardDate,
|
||||
getClockCardDateConfig,
|
||||
hasClockCardDate,
|
||||
resolveClockCardLocale,
|
||||
} from "./clock-date-format";
|
||||
|
||||
function romanize12HourClock(num: number) {
|
||||
const numerals = [
|
||||
@@ -34,11 +26,6 @@ function romanize12HourClock(num: number) {
|
||||
return numerals[num];
|
||||
}
|
||||
|
||||
const DATE_UPDATE_INTERVAL = 60_000;
|
||||
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;
|
||||
@@ -53,18 +40,42 @@ export class HuiClockCardAnalog extends LitElement {
|
||||
|
||||
@state() private _secondOffsetSec?: number;
|
||||
|
||||
@state() private _date?: string;
|
||||
private _initDate() {
|
||||
if (!this.config || !this.hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
private _dateInterval?: number;
|
||||
let locale = this.hass.locale;
|
||||
if (this.config.time_format) {
|
||||
locale = { ...locale, time_format: this.config.time_format };
|
||||
}
|
||||
|
||||
private _timeZone?: string;
|
||||
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),
|
||||
});
|
||||
|
||||
private _language?: string;
|
||||
this._computeOffsets();
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
if (changedProps.has("hass")) {
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||
if (!oldHass || oldHass.locale !== this.hass?.locale) {
|
||||
this._initDate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
document.addEventListener("visibilitychange", this._handleVisibilityChange);
|
||||
this._initDate();
|
||||
this._computeOffsets();
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
@@ -73,80 +84,18 @@ export class HuiClockCardAnalog extends LitElement {
|
||||
"visibilitychange",
|
||||
this._handleVisibilityChange
|
||||
);
|
||||
this._stopDateTick();
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
if (changedProps.has("config") || changedProps.has("hass")) {
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||
if (
|
||||
changedProps.has("config") ||
|
||||
!oldHass ||
|
||||
oldHass.locale !== this.hass?.locale
|
||||
) {
|
||||
this._initDate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _handleVisibilityChange = () => {
|
||||
if (!document.hidden) {
|
||||
this._computeOffsets();
|
||||
this._updateDate();
|
||||
}
|
||||
};
|
||||
|
||||
private _initDate() {
|
||||
if (!this.config || !this.hass) {
|
||||
this._stopDateTick();
|
||||
this._date = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const { timeZone } = resolveClockCardLocale(this.hass, this.config);
|
||||
|
||||
this._language = this.hass.locale.language;
|
||||
this._timeZone = timeZone;
|
||||
|
||||
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hourCycle: "h12",
|
||||
timeZone,
|
||||
});
|
||||
|
||||
this._computeOffsets();
|
||||
this._updateDate();
|
||||
|
||||
if (this.isConnected && hasClockCardDate(this.config)) {
|
||||
this._startDateTick();
|
||||
} else {
|
||||
this._stopDateTick();
|
||||
}
|
||||
}
|
||||
|
||||
private _startDateTick() {
|
||||
this._stopDateTick();
|
||||
this._dateInterval = window.setInterval(
|
||||
() => this._updateDate(),
|
||||
DATE_UPDATE_INTERVAL
|
||||
);
|
||||
}
|
||||
|
||||
private _stopDateTick() {
|
||||
if (this._dateInterval) {
|
||||
clearInterval(this._dateInterval);
|
||||
this._dateInterval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _computeOffsets() {
|
||||
if (!this._dateTimeFormat) return;
|
||||
|
||||
const date = new Date();
|
||||
|
||||
const parts = this._dateTimeFormat.formatToParts(date);
|
||||
const parts = this._dateTimeFormat.formatToParts();
|
||||
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;
|
||||
@@ -154,7 +103,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 = date.getMilliseconds();
|
||||
const ms = new Date().getMilliseconds();
|
||||
const secondsWithMs = second + ms / 1000;
|
||||
|
||||
const hour12 = hour % 12;
|
||||
@@ -164,45 +113,16 @@ export class HuiClockCardAnalog extends LitElement {
|
||||
this._hourOffsetSec = hour12 * 3600 + minute * 60 + secondsWithMs;
|
||||
}
|
||||
|
||||
private _updateDate() {
|
||||
if (!this.config || !hasClockCardDate(this.config) || !this._language) {
|
||||
this._date = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const dateConfig = getClockCardDateConfig(this.config);
|
||||
this._date = formatClockCardDate(
|
||||
new Date(),
|
||||
dateConfig,
|
||||
this._language,
|
||||
this._timeZone
|
||||
);
|
||||
}
|
||||
|
||||
private _computeClock = memoizeOne((config: ClockCardConfig) => {
|
||||
const faceParts = config.face_style?.split("_");
|
||||
const dateConfig = getClockCardDateConfig(config);
|
||||
const showDate = hasClockCardDate(config);
|
||||
const isLongDate =
|
||||
dateConfig.parts.includes("month-long") ||
|
||||
dateConfig.parts.includes("weekday-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,
|
||||
showDate,
|
||||
isLongDate,
|
||||
};
|
||||
});
|
||||
|
||||
render() {
|
||||
if (!this.config) return nothing;
|
||||
|
||||
const { sizeClass, isNumbers, isRoman, isUpright, isLongDate, showDate } =
|
||||
this._computeClock(this.config);
|
||||
const dateLines = this._date?.split("\n") ?? [];
|
||||
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 indicator = (number?: number) => html`
|
||||
<div
|
||||
@@ -248,14 +168,14 @@ export class HuiClockCardAnalog extends LitElement {
|
||||
>
|
||||
${
|
||||
this.config.ticks === "quarter"
|
||||
? QUARTER_TICKS.map(
|
||||
? Array.from({ length: 4 }, (_, i) => i).map(
|
||||
(i) =>
|
||||
// 4 ticks (12, 3, 6, 9) at 0°, 90°, 180°, 270°
|
||||
html`
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="tick hour"
|
||||
style=${styleMap({ "--tick-rotation": `${i * 90}deg` })}
|
||||
style=${`--tick-rotation: ${i * 90}deg;`}
|
||||
>
|
||||
${indicator([12, 3, 6, 9][i])}
|
||||
</div>
|
||||
@@ -263,30 +183,28 @@ export class HuiClockCardAnalog extends LitElement {
|
||||
)
|
||||
: !this.config.ticks || // Default to hour ticks
|
||||
this.config.ticks === "hour"
|
||||
? HOUR_TICKS.map(
|
||||
? Array.from({ length: 12 }, (_, i) => i).map(
|
||||
(i) =>
|
||||
// 12 ticks (1-12)
|
||||
html`
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="tick hour"
|
||||
style=${styleMap({ "--tick-rotation": `${i * 30}deg` })}
|
||||
style=${`--tick-rotation: ${i * 30}deg;`}
|
||||
>
|
||||
${indicator(((i + 11) % 12) + 1)}
|
||||
</div>
|
||||
`
|
||||
)
|
||||
: this.config.ticks === "minute"
|
||||
? MINUTE_TICKS.map(
|
||||
? Array.from({ length: 60 }, (_, i) => i).map(
|
||||
(i) =>
|
||||
// 60 ticks (1-60)
|
||||
html`
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="tick ${i % 5 === 0 ? "hour" : "minute"}"
|
||||
style=${styleMap({
|
||||
"--tick-rotation": `${i * 6}deg`,
|
||||
})}
|
||||
style=${`--tick-rotation: ${i * 6}deg;`}
|
||||
>
|
||||
${
|
||||
i % 5 === 0
|
||||
@@ -298,43 +216,14 @@ export class HuiClockCardAnalog extends LitElement {
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
showDate
|
||||
? html`<div
|
||||
class=${classMap({
|
||||
date: true,
|
||||
[sizeClass]: true,
|
||||
"long-date": isLongDate,
|
||||
})}
|
||||
>
|
||||
${dateLines.map(
|
||||
(line) => html`
|
||||
<ha-marquee-text
|
||||
class="date-line"
|
||||
speed="5"
|
||||
pause-duration="1500"
|
||||
pause-on-hover
|
||||
>
|
||||
${line}
|
||||
</ha-marquee-text>
|
||||
`
|
||||
)}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
|
||||
<div class="center-dot"></div>
|
||||
<div
|
||||
class="hand hour"
|
||||
style=${styleMap({
|
||||
"animation-delay": `-${this._hourOffsetSec ?? 0}s`,
|
||||
})}
|
||||
style=${`animation-delay: -${this._hourOffsetSec ?? 0}s;`}
|
||||
></div>
|
||||
<div
|
||||
class="hand minute"
|
||||
style=${styleMap({
|
||||
"animation-delay": `-${this._minuteOffsetSec ?? 0}s`,
|
||||
})}
|
||||
style=${`animation-delay: -${this._minuteOffsetSec ?? 0}s;`}
|
||||
></div>
|
||||
${
|
||||
this.config.show_seconds
|
||||
@@ -344,13 +233,11 @@ export class HuiClockCardAnalog extends LitElement {
|
||||
second: true,
|
||||
step: this.config.seconds_motion === "tick",
|
||||
})}
|
||||
style=${styleMap({
|
||||
"animation-delay": `-${
|
||||
this.config.seconds_motion === "tick"
|
||||
? Math.floor(this._secondOffsetSec ?? 0)
|
||||
: (this._secondOffsetSec ?? 0)
|
||||
}s`,
|
||||
})}
|
||||
style=${`animation-delay: -${
|
||||
(this.config.seconds_motion === "tick"
|
||||
? Math.floor(this._secondOffsetSec ?? 0)
|
||||
: (this._secondOffsetSec ?? 0)) as number
|
||||
}s;`}
|
||||
></div>`
|
||||
: nothing
|
||||
}
|
||||
@@ -530,42 +417,6 @@ export class HuiClockCardAnalog extends LitElement {
|
||||
transform: translate(-50%, 0) rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.date {
|
||||
position: absolute;
|
||||
top: 68%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: block;
|
||||
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;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.date-line {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.date.long-date:not(.size-medium):not(.size-large) {
|
||||
top: 66%;
|
||||
font-size: var(--ha-font-size-xs);
|
||||
line-height: 1;
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
.date.size-medium {
|
||||
font-size: var(--ha-font-size-l);
|
||||
}
|
||||
|
||||
.date.size-large {
|
||||
font-size: var(--ha-font-size-xl);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../../../../components/ha-marquee-text";
|
||||
import type { ClockCardConfig } from "../types";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { useAmPm } from "../../../../common/datetime/use_am_pm";
|
||||
import {
|
||||
formatClockCardDate,
|
||||
getClockCardDateConfig,
|
||||
hasClockCardDate,
|
||||
resolveClockCardLocale,
|
||||
} from "./clock-date-format";
|
||||
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
|
||||
|
||||
const INTERVAL = 1000;
|
||||
|
||||
@@ -30,50 +24,37 @@ export class HuiClockCardDigital extends LitElement {
|
||||
|
||||
@state() private _timeAmPm?: string;
|
||||
|
||||
@state() private _date?: string;
|
||||
|
||||
private _tickInterval?: undefined | number;
|
||||
|
||||
private _lastDateMinute?: string;
|
||||
|
||||
private _timeZone?: string;
|
||||
|
||||
private _language?: string;
|
||||
|
||||
private _initDate() {
|
||||
if (!this.config || !this.hass) {
|
||||
this._date = undefined;
|
||||
this._lastDateMinute = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const { locale, timeZone } = resolveClockCardLocale(this.hass, this.config);
|
||||
let locale = this.hass?.locale;
|
||||
|
||||
if (this.config?.time_format) {
|
||||
locale = { ...locale, time_format: this.config.time_format };
|
||||
}
|
||||
|
||||
const h12 = useAmPm(locale);
|
||||
this._language = this.hass.locale.language;
|
||||
this._timeZone = timeZone;
|
||||
|
||||
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
|
||||
hour: h12 ? "numeric" : "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hourCycle: h12 ? "h12" : "h23",
|
||||
timeZone,
|
||||
timeZone:
|
||||
this.config?.time_zone ||
|
||||
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone),
|
||||
});
|
||||
|
||||
this._lastDateMinute = undefined;
|
||||
|
||||
this._tick();
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
if (changedProps.has("config") || changedProps.has("hass")) {
|
||||
if (changedProps.has("hass")) {
|
||||
const oldHass = changedProps.get("hass");
|
||||
if (
|
||||
changedProps.has("config") ||
|
||||
!oldHass ||
|
||||
oldHass.locale !== this.hass?.locale
|
||||
) {
|
||||
if (!oldHass || oldHass.locale !== this.hass?.locale) {
|
||||
this._initDate();
|
||||
}
|
||||
}
|
||||
@@ -90,7 +71,6 @@ export class HuiClockCardDigital extends LitElement {
|
||||
}
|
||||
|
||||
private _startTick() {
|
||||
this._stopTick();
|
||||
this._tickInterval = window.setInterval(() => this._tick(), INTERVAL);
|
||||
this._tick();
|
||||
}
|
||||
@@ -105,8 +85,7 @@ export class HuiClockCardDigital extends LitElement {
|
||||
private _tick() {
|
||||
if (!this._dateTimeFormat) return;
|
||||
|
||||
const date = new Date();
|
||||
const parts = this._dateTimeFormat.formatToParts(date);
|
||||
const parts = this._dateTimeFormat.formatToParts();
|
||||
|
||||
this._timeHour = parts.find((part) => part.type === "hour")?.value;
|
||||
this._timeMinute = parts.find((part) => part.type === "minute")?.value;
|
||||
@@ -114,33 +93,6 @@ export class HuiClockCardDigital extends LitElement {
|
||||
? parts.find((part) => part.type === "second")?.value
|
||||
: undefined;
|
||||
this._timeAmPm = parts.find((part) => part.type === "dayPeriod")?.value;
|
||||
|
||||
this._updateDate(date);
|
||||
}
|
||||
|
||||
private _updateDate(date: Date) {
|
||||
if (!this.config || !hasClockCardDate(this.config) || !this._language) {
|
||||
this._date = undefined;
|
||||
this._lastDateMinute = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this._timeMinute !== undefined &&
|
||||
this._timeMinute === this._lastDateMinute &&
|
||||
this._date !== undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dateConfig = getClockCardDateConfig(this.config);
|
||||
this._date = formatClockCardDate(
|
||||
date,
|
||||
dateConfig,
|
||||
this._language,
|
||||
this._timeZone
|
||||
);
|
||||
this._lastDateMinute = this._timeMinute;
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -149,65 +101,28 @@ export class HuiClockCardDigital extends LitElement {
|
||||
const sizeClass = this.config.clock_size
|
||||
? `size-${this.config.clock_size}`
|
||||
: "";
|
||||
const showDate = hasClockCardDate(this.config);
|
||||
|
||||
return html`
|
||||
<div class="clock-container">
|
||||
<div class="time-parts ${sizeClass}">
|
||||
<div class="time-part hour">${this._timeHour}</div>
|
||||
<div class="time-part minute">${this._timeMinute}</div>
|
||||
${
|
||||
this._timeSecond !== undefined
|
||||
? html`<div class="time-part second">${this._timeSecond}</div>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._timeAmPm !== undefined
|
||||
? html`<div class="time-part am-pm">${this._timeAmPm}</div>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<div class="time-parts ${sizeClass}">
|
||||
<div class="time-part hour">${this._timeHour}</div>
|
||||
<div class="time-part minute">${this._timeMinute}</div>
|
||||
${
|
||||
this._timeSecond !== undefined
|
||||
? html`<div class="time-part second">${this._timeSecond}</div>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._timeAmPm !== undefined
|
||||
? html`<div class="time-part am-pm">${this._timeAmPm}</div>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
showDate
|
||||
? html`<div class="date-container">
|
||||
<div class="date ${sizeClass}">
|
||||
${this._date
|
||||
?.split("\n")
|
||||
.map(
|
||||
(line) => html`
|
||||
<ha-marquee-text
|
||||
class="date-line"
|
||||
speed="10"
|
||||
pause-duration="1500"
|
||||
pause-on-hover
|
||||
>
|
||||
${line}
|
||||
</ha-marquee-text>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.clock-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.date-container {
|
||||
width: 100%;
|
||||
margin-top: var(--ha-space-1);
|
||||
}
|
||||
|
||||
.time-parts {
|
||||
@@ -277,29 +192,6 @@ export class HuiClockCardDigital extends LitElement {
|
||||
content: ":";
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.date {
|
||||
margin-inline: auto;
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
font-size: var(--ha-font-size-s);
|
||||
line-height: 1.1;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.date-line {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.date.size-medium {
|
||||
font-size: var(--ha-font-size-l);
|
||||
}
|
||||
|
||||
.date.size-large {
|
||||
font-size: var(--ha-font-size-2xl);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -445,29 +445,12 @@ export interface ClockCardConfig extends LovelaceCardConfig {
|
||||
time_format?: TimeFormat;
|
||||
time_zone?: string;
|
||||
no_background?: boolean;
|
||||
date_format?: ClockCardDatePart[];
|
||||
// Analog clock options
|
||||
border?: boolean;
|
||||
ticks?: "none" | "quarter" | "hour" | "minute";
|
||||
face_style?: "markers" | "numbers_upright" | "roman";
|
||||
}
|
||||
|
||||
export type ClockCardDatePart =
|
||||
| "weekday-short"
|
||||
| "weekday-long"
|
||||
| "day-numeric"
|
||||
| "day-2-digit"
|
||||
| "month-short"
|
||||
| "month-long"
|
||||
| "month-numeric"
|
||||
| "month-2-digit"
|
||||
| "year-2-digit"
|
||||
| "year-numeric"
|
||||
| "separator-dash"
|
||||
| "separator-slash"
|
||||
| "separator-dot"
|
||||
| "separator-new-line";
|
||||
|
||||
export interface MediaControlCardConfig extends LovelaceCardConfig {
|
||||
entity: string;
|
||||
name?: string | EntityNameItem | EntityNameItem[];
|
||||
|
||||
@@ -2,15 +2,16 @@ import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
array,
|
||||
assert,
|
||||
assign,
|
||||
boolean,
|
||||
defaulted,
|
||||
enums,
|
||||
literal,
|
||||
object,
|
||||
optional,
|
||||
string,
|
||||
union,
|
||||
} from "superstruct";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
@@ -18,47 +19,58 @@ import type {
|
||||
HaFormSchema,
|
||||
SchemaUnion,
|
||||
} from "../../../../components/ha-form/types";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import type { ClockCardConfig } from "../../cards/types";
|
||||
import type { LovelaceCardEditor } from "../../types";
|
||||
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
|
||||
import { TimeFormat } from "../../../../data/translation";
|
||||
import { getTimezoneOptions } from "../../../../components/ha-timezone-picker";
|
||||
import {
|
||||
CLOCK_CARD_DATE_PARTS,
|
||||
getClockCardDateConfig,
|
||||
} from "../../cards/clock/clock-date-format";
|
||||
|
||||
const cardConfigStruct = assign(
|
||||
baseLovelaceCardConfig,
|
||||
object({
|
||||
title: optional(string()),
|
||||
clock_style: optional(enums(["digital", "analog"])),
|
||||
clock_size: optional(enums(["small", "medium", "large"])),
|
||||
clock_style: optional(union([literal("digital"), literal("analog")])),
|
||||
clock_size: optional(
|
||||
union([literal("small"), literal("medium"), literal("large")])
|
||||
),
|
||||
time_format: optional(enums(Object.values(TimeFormat))),
|
||||
time_zone: optional(enums(getTimezoneOptions().map((option) => option.id))),
|
||||
show_seconds: optional(boolean()),
|
||||
no_background: optional(boolean()),
|
||||
date_format: optional(defaulted(array(enums(CLOCK_CARD_DATE_PARTS)), [])),
|
||||
// Analog clock options
|
||||
border: optional(defaulted(boolean(), false)),
|
||||
ticks: optional(
|
||||
defaulted(enums(["none", "quarter", "hour", "minute"]), "hour")
|
||||
defaulted(
|
||||
union([
|
||||
literal("none"),
|
||||
literal("quarter"),
|
||||
literal("hour"),
|
||||
literal("minute"),
|
||||
]),
|
||||
literal("hour")
|
||||
)
|
||||
),
|
||||
seconds_motion: optional(
|
||||
defaulted(enums(["continuous", "tick"]), "continuous")
|
||||
defaulted(
|
||||
union([literal("continuous"), literal("tick")]),
|
||||
literal("continuous")
|
||||
)
|
||||
),
|
||||
face_style: optional(
|
||||
defaulted(enums(["markers", "numbers_upright", "roman"]), "markers")
|
||||
defaulted(
|
||||
union([
|
||||
literal("markers"),
|
||||
literal("numbers_upright"),
|
||||
literal("roman"),
|
||||
]),
|
||||
literal("markers")
|
||||
)
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
type ClockCardFormData = Omit<ClockCardConfig, "time_format"> & {
|
||||
time_format?: ClockCardConfig["time_format"] | "auto";
|
||||
};
|
||||
|
||||
@customElement("hui-clock-card-editor")
|
||||
export class HuiClockCardEditor
|
||||
extends LitElement
|
||||
@@ -81,7 +93,7 @@ export class HuiClockCardEditor
|
||||
name: "clock_style",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "box",
|
||||
mode: "dropdown",
|
||||
options: ["digital", "analog"].map((value) => ({
|
||||
value,
|
||||
label: localize(
|
||||
@@ -107,13 +119,6 @@ export class HuiClockCardEditor
|
||||
},
|
||||
{ name: "show_seconds", selector: { boolean: {} } },
|
||||
{ name: "no_background", selector: { boolean: {} } },
|
||||
{
|
||||
name: "date_format",
|
||||
required: false,
|
||||
selector: {
|
||||
ui_clock_date_format: {},
|
||||
},
|
||||
},
|
||||
...(clockStyle === "digital"
|
||||
? ([
|
||||
{
|
||||
@@ -236,28 +241,18 @@ export class HuiClockCardEditor
|
||||
] as const satisfies readonly HaFormSchema[]
|
||||
);
|
||||
|
||||
private _data = memoizeOne((config: ClockCardConfig): ClockCardFormData => {
|
||||
const dateConfig = getClockCardDateConfig(config);
|
||||
|
||||
const data: ClockCardFormData = {
|
||||
...config,
|
||||
clock_style: config.clock_style ?? "digital",
|
||||
clock_size: config.clock_size ?? "small",
|
||||
time_format: config.time_format ?? "auto",
|
||||
show_seconds: config.show_seconds ?? false,
|
||||
no_background: config.no_background ?? false,
|
||||
// Analog clock options
|
||||
border: config.border ?? false,
|
||||
ticks: config.ticks ?? "hour",
|
||||
face_style: config.face_style ?? "markers",
|
||||
};
|
||||
|
||||
if (config.date_format === undefined) {
|
||||
data.date_format = dateConfig.parts;
|
||||
}
|
||||
|
||||
return data;
|
||||
});
|
||||
private _data = memoizeOne((config) => ({
|
||||
clock_style: "digital",
|
||||
clock_size: "small",
|
||||
time_format: "auto",
|
||||
show_seconds: false,
|
||||
no_background: false,
|
||||
// Analog clock options
|
||||
border: false,
|
||||
ticks: "hour",
|
||||
face_style: "markers",
|
||||
...config,
|
||||
}));
|
||||
|
||||
public setConfig(config: ClockCardConfig): void {
|
||||
assert(config, cardConfigStruct);
|
||||
@@ -286,40 +281,35 @@ export class HuiClockCardEditor
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueChanged(ev: ValueChangedEvent<ClockCardFormData>): void {
|
||||
const config = ev.detail.value;
|
||||
|
||||
if (!config.date_format || config.date_format.length === 0) {
|
||||
delete config.date_format;
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
if (ev.detail.value.time_format === "auto") {
|
||||
delete ev.detail.value.time_format;
|
||||
}
|
||||
|
||||
if (config.time_format === "auto") {
|
||||
delete config.time_format;
|
||||
}
|
||||
|
||||
if (config.clock_style === "analog") {
|
||||
config.border = config.border ?? false;
|
||||
config.ticks = config.ticks ?? "hour";
|
||||
config.face_style = config.face_style ?? "markers";
|
||||
if (config.show_seconds) {
|
||||
config.seconds_motion = config.seconds_motion ?? "continuous";
|
||||
if (ev.detail.value.clock_style === "analog") {
|
||||
ev.detail.value.border = ev.detail.value.border ?? false;
|
||||
ev.detail.value.ticks = ev.detail.value.ticks ?? "hour";
|
||||
ev.detail.value.face_style = ev.detail.value.face_style ?? "markers";
|
||||
if (ev.detail.value.show_seconds) {
|
||||
ev.detail.value.seconds_motion =
|
||||
ev.detail.value.seconds_motion ?? "continuous";
|
||||
} else {
|
||||
delete config.seconds_motion;
|
||||
delete ev.detail.value.seconds_motion;
|
||||
}
|
||||
} else {
|
||||
delete config.border;
|
||||
delete config.ticks;
|
||||
delete config.face_style;
|
||||
delete config.seconds_motion;
|
||||
delete ev.detail.value.border;
|
||||
delete ev.detail.value.ticks;
|
||||
delete ev.detail.value.face_style;
|
||||
delete ev.detail.value.seconds_motion;
|
||||
}
|
||||
|
||||
if (config.ticks !== "none") {
|
||||
config.face_style = config.face_style ?? "markers";
|
||||
if (ev.detail.value.ticks !== "none") {
|
||||
ev.detail.value.face_style = ev.detail.value.face_style ?? "markers";
|
||||
} else {
|
||||
delete config.face_style;
|
||||
delete ev.detail.value.face_style;
|
||||
}
|
||||
|
||||
fireEvent(this, "config-changed", { config });
|
||||
fireEvent(this, "config-changed", { config: ev.detail.value });
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
@@ -354,10 +344,6 @@ export class HuiClockCardEditor
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.no_background`
|
||||
);
|
||||
case "date_format":
|
||||
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`
|
||||
@@ -383,10 +369,6 @@ export class HuiClockCardEditor
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) => {
|
||||
switch (schema.name) {
|
||||
case "date_format":
|
||||
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`
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -261,10 +260,9 @@ export class HuiHistoryGraphCardEditor
|
||||
const domain = computeDomain(entityId);
|
||||
const isNumberDomain =
|
||||
domain === "counter" || domain === "number" || domain === "input_number";
|
||||
const stateObj = this.hass!.states[entityId] as HassEntity | undefined;
|
||||
const stateObj = this.hass!.states[entityId];
|
||||
const attributes = stateObj?.attributes;
|
||||
const isNumeric = attributes ? isNumericFromAttributes(attributes) : false;
|
||||
return !isNumeric && !isNumberDomain;
|
||||
return !isNumericFromAttributes(attributes) && !isNumberDomain;
|
||||
};
|
||||
|
||||
// remove "color" option when needed
|
||||
|
||||
@@ -1486,6 +1486,7 @@
|
||||
"auto_revert": "Settings will automatically revert in {time}.",
|
||||
"reverted": "Home Assistant reverted the HTTP server settings.",
|
||||
"changes_label": "Changed settings:",
|
||||
"not_set": "Not set",
|
||||
"confirm": "Confirm",
|
||||
"revert": "Revert",
|
||||
"close": "Close",
|
||||
@@ -8679,6 +8680,10 @@
|
||||
"description": "Configure how Home Assistant serves its web interface. Saving restarts Home Assistant.",
|
||||
"save": "Save",
|
||||
"save_no_changes": "Nothing changed — no restart needed.",
|
||||
"save_error": "Could not save the HTTP configuration.",
|
||||
"port_warning": "Changing the server port breaks the connection for the Home Assistant mobile apps and other clients until you update the port in their settings.",
|
||||
"invalid_host": "Enter a valid IP address.",
|
||||
"invalid_network": "Enter a valid IP address or network.",
|
||||
"save_confirm": {
|
||||
"title": "Restart required",
|
||||
"text": "Saving will restart Home Assistant to apply the new HTTP settings.",
|
||||
@@ -8690,7 +8695,7 @@
|
||||
"ssl": "SSL/TLS",
|
||||
"reverse_proxy": "Reverse proxy",
|
||||
"ip_banning": "IP banning",
|
||||
"advanced": "Advanced"
|
||||
"advanced": "More options"
|
||||
},
|
||||
"fields": {
|
||||
"server_port": "Server port",
|
||||
@@ -10140,33 +10145,6 @@
|
||||
"large": "Large"
|
||||
},
|
||||
"show_seconds": "Display seconds",
|
||||
"date": {
|
||||
"label": "Date",
|
||||
"description": "Select and order the date parts. Add a separator to control punctuation.",
|
||||
"sections": {
|
||||
"weekday": "Weekday",
|
||||
"day": "Day",
|
||||
"month": "Month",
|
||||
"year": "Year",
|
||||
"separator": "Separator"
|
||||
},
|
||||
"parts": {
|
||||
"weekday-short": "Short",
|
||||
"weekday-long": "Long",
|
||||
"day-numeric": "Numeric",
|
||||
"day-2-digit": "2-digit",
|
||||
"month-short": "Short",
|
||||
"month-long": "Long",
|
||||
"month-numeric": "Numeric",
|
||||
"month-2-digit": "2-digit",
|
||||
"year-2-digit": "2-digit",
|
||||
"year-numeric": "Full",
|
||||
"separator-dash": "Dash",
|
||||
"separator-slash": "Slash",
|
||||
"separator-dot": "Dot",
|
||||
"separator-new-line": "New line"
|
||||
}
|
||||
},
|
||||
"time_format": "[%key:ui::panel::profile::time_format::dropdown_label%]",
|
||||
"time_formats": {
|
||||
"auto": "Use user settings",
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
IP_ADDRESS_OR_NETWORK_PATTERN,
|
||||
IP_ADDRESS_PATTERN,
|
||||
isIPAddress,
|
||||
isIPAddressV4OrV6,
|
||||
isIPNetwork,
|
||||
isIPv6Address,
|
||||
} from "../../../src/common/string/is_ip_address";
|
||||
|
||||
describe("isIPAddress (IPv4)", () => {
|
||||
it("accepts valid IPv4 addresses", () => {
|
||||
expect(isIPAddress("192.168.1.10")).toBe(true);
|
||||
expect(isIPAddress("0.0.0.0")).toBe(true);
|
||||
expect(isIPAddress("255.255.255.255")).toBe(true);
|
||||
});
|
||||
it("rejects invalid IPv4 addresses", () => {
|
||||
expect(isIPAddress("256.1.1.1")).toBe(false);
|
||||
expect(isIPAddress("192.168.1")).toBe(false);
|
||||
expect(isIPAddress("192.168.1.10/24")).toBe(false);
|
||||
expect(isIPAddress("fe80::1")).toBe(false);
|
||||
expect(isIPAddress("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isIPv6Address", () => {
|
||||
it("accepts valid IPv6 addresses", () => {
|
||||
expect(isIPv6Address("fe80::85d:e82c:9446:7995")).toBe(true);
|
||||
expect(isIPv6Address("::1")).toBe(true);
|
||||
expect(isIPv6Address("1050:0000:0000:0000:0005:0600:300c:326b")).toBe(true);
|
||||
expect(isIPv6Address("::ffff:192.168.1.1")).toBe(true);
|
||||
});
|
||||
it("rejects invalid IPv6 addresses", () => {
|
||||
expect(isIPv6Address("192.168.1.10")).toBe(false);
|
||||
expect(isIPv6Address("fe80::85d::7995")).toBe(false);
|
||||
expect(isIPv6Address("gggg::1")).toBe(false);
|
||||
expect(isIPv6Address("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isIPAddressV4OrV6", () => {
|
||||
it("accepts both families", () => {
|
||||
expect(isIPAddressV4OrV6("192.168.1.10")).toBe(true);
|
||||
expect(isIPAddressV4OrV6("fe80::1")).toBe(true);
|
||||
});
|
||||
it("rejects networks and garbage", () => {
|
||||
expect(isIPAddressV4OrV6("192.168.1.0/24")).toBe(false);
|
||||
expect(isIPAddressV4OrV6("not-an-ip")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isIPNetwork (CIDR)", () => {
|
||||
it("accepts valid networks", () => {
|
||||
expect(isIPNetwork("192.168.1.0/24")).toBe(true);
|
||||
expect(isIPNetwork("10.0.0.0/8")).toBe(true);
|
||||
expect(isIPNetwork("172.16.0.0/12")).toBe(true);
|
||||
expect(isIPNetwork("0.0.0.0/0")).toBe(true);
|
||||
expect(isIPNetwork("fd00::/8")).toBe(true);
|
||||
expect(isIPNetwork("fe80::/128")).toBe(true);
|
||||
});
|
||||
it("rejects invalid networks", () => {
|
||||
// Prefix out of range — the reported production error.
|
||||
expect(isIPNetwork("172.30.33.0/24444444")).toBe(false);
|
||||
expect(isIPNetwork("192.168.1.0/33")).toBe(false);
|
||||
expect(isIPNetwork("fd00::/129")).toBe(false);
|
||||
expect(isIPNetwork("192.168.1.0")).toBe(false);
|
||||
expect(isIPNetwork("192.168.1.0/24/24")).toBe(false);
|
||||
expect(isIPNetwork("not-a-network/24")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The HTML `pattern` attribute is anchored by the browser as `^(?:…)$`.
|
||||
const matchesPattern = (pattern: string, value: string): boolean =>
|
||||
new RegExp(`^(?:${pattern})$`).test(value);
|
||||
|
||||
describe("IP_ADDRESS_PATTERN", () => {
|
||||
it("accepts IPv4 and IPv6 addresses", () => {
|
||||
expect(matchesPattern(IP_ADDRESS_PATTERN, "192.168.1.10")).toBe(true);
|
||||
expect(matchesPattern(IP_ADDRESS_PATTERN, "fe80::1")).toBe(true);
|
||||
});
|
||||
it("rejects networks and garbage", () => {
|
||||
expect(matchesPattern(IP_ADDRESS_PATTERN, "192.168.1.0/24")).toBe(false);
|
||||
expect(matchesPattern(IP_ADDRESS_PATTERN, "not-an-ip")).toBe(false);
|
||||
expect(matchesPattern(IP_ADDRESS_PATTERN, "256.1.1.1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IP_ADDRESS_OR_NETWORK_PATTERN", () => {
|
||||
it("accepts addresses and networks", () => {
|
||||
expect(matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "192.168.1.10")).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "192.168.1.0/24")
|
||||
).toBe(true);
|
||||
expect(matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "fd00::/8")).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
it("rejects out-of-range prefixes and garbage", () => {
|
||||
// The reported production error.
|
||||
expect(
|
||||
matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "172.30.33.0/24444444")
|
||||
).toBe(false);
|
||||
expect(
|
||||
matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "192.168.1.0/33")
|
||||
).toBe(false);
|
||||
expect(
|
||||
matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "1.1.1.1/233444")
|
||||
).toBe(false);
|
||||
expect(matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "not-an-ip")).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -483,7 +483,6 @@ test.describe("Theming", () => {
|
||||
() =>
|
||||
(document.querySelector("ha-test") as any)?.hass?.themes?.darkMode ===
|
||||
true,
|
||||
undefined,
|
||||
{ timeout: QUICK_TIMEOUT }
|
||||
);
|
||||
});
|
||||
@@ -500,7 +499,6 @@ test.describe("Theming", () => {
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--primary-color")
|
||||
.trim() !== "",
|
||||
undefined,
|
||||
{ timeout: QUICK_TIMEOUT }
|
||||
);
|
||||
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { assert, describe, it } from "vitest";
|
||||
import { createHassioSession } from "../../src/data/hassio/ingress";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
|
||||
describe("Create hassio session", () => {
|
||||
const hass = {
|
||||
callWS: async () => ({
|
||||
session: "fhdsu73rh3io4h8f3irhjel8ousafehf8f3yh",
|
||||
config: { version: "1.0.0" },
|
||||
callApi: async () => ({
|
||||
data: { session: "fhdsu73rh3io4h8f3irhjel8ousafehf8f3yh" },
|
||||
}),
|
||||
} as unknown as Pick<HomeAssistant, "callWS">;
|
||||
};
|
||||
|
||||
it("Test create session without HTTPS", async () => {
|
||||
// @ts-ignore
|
||||
global.document = {};
|
||||
// @ts-ignore
|
||||
global.location = {};
|
||||
// @ts-ignore
|
||||
await createHassioSession(hass);
|
||||
assert.strictEqual(
|
||||
// @ts-ignore
|
||||
@@ -26,6 +27,7 @@ describe("Create hassio session", () => {
|
||||
global.document = {};
|
||||
// @ts-ignore
|
||||
global.location = { protocol: "https:" };
|
||||
// @ts-ignore
|
||||
await createHassioSession(hass);
|
||||
assert.strictEqual(
|
||||
// @ts-ignore
|
||||
@@ -41,8 +43,9 @@ describe("Create hassio session", () => {
|
||||
});
|
||||
it("Test fail to create", async () => {
|
||||
const createSessionPromise = createHassioSession({
|
||||
callWS: async () => {
|
||||
throw new Error("Failed to create session");
|
||||
// @ts-ignore
|
||||
callApi: async () => {
|
||||
// noop
|
||||
},
|
||||
}).then(
|
||||
() => true,
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
import { assert, describe, it } from "vitest";
|
||||
import type { ClockCardDatePart } from "../../../../src/panels/lovelace/cards/types";
|
||||
import {
|
||||
formatClockCardDate,
|
||||
getClockCardDateConfig,
|
||||
getClockCardDateTimeFormatOptions,
|
||||
hasClockCardDate,
|
||||
} from "../../../../src/panels/lovelace/cards/clock/clock-date-format";
|
||||
|
||||
describe("clock-date-format", () => {
|
||||
const date = new Date("2024-11-08T10:20:30.000Z");
|
||||
|
||||
it("returns an empty config when date format is missing", () => {
|
||||
assert.deepEqual(getClockCardDateConfig(), { parts: [] });
|
||||
});
|
||||
|
||||
it("preserves literal token order", () => {
|
||||
const config = getClockCardDateConfig({
|
||||
date_format: [
|
||||
"day-numeric",
|
||||
"separator-dot",
|
||||
"month-short",
|
||||
"month-long",
|
||||
"separator-slash",
|
||||
"year-2-digit",
|
||||
"year-numeric",
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(config.parts, [
|
||||
"day-numeric",
|
||||
"separator-dot",
|
||||
"month-short",
|
||||
"month-long",
|
||||
"separator-slash",
|
||||
"year-2-digit",
|
||||
"year-numeric",
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters invalid date tokens", () => {
|
||||
const config = getClockCardDateConfig({
|
||||
date_format: [
|
||||
"month-short",
|
||||
"invalid",
|
||||
"year-2-digit",
|
||||
] as unknown as ClockCardDatePart[],
|
||||
});
|
||||
|
||||
assert.deepEqual(config.parts, ["month-short", "year-2-digit"]);
|
||||
});
|
||||
|
||||
it("builds Intl options from selected date tokens", () => {
|
||||
const options = getClockCardDateTimeFormatOptions({
|
||||
parts: [
|
||||
"weekday-short",
|
||||
"separator-slash",
|
||||
"day-2-digit",
|
||||
"month-long",
|
||||
"month-numeric",
|
||||
"year-2-digit",
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(options, {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "numeric",
|
||||
year: "2-digit",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports whether any date part is configured", () => {
|
||||
assert.equal(hasClockCardDate(), false);
|
||||
assert.equal(hasClockCardDate({ date_format: [] }), false);
|
||||
assert.equal(hasClockCardDate({ date_format: ["separator-dot"] }), true);
|
||||
assert.equal(
|
||||
hasClockCardDate({ date_format: ["separator-new-line"] }),
|
||||
true
|
||||
);
|
||||
assert.equal(hasClockCardDate({ date_format: ["weekday-short"] }), true);
|
||||
});
|
||||
|
||||
it("formats output in configured part order with literal separators", () => {
|
||||
const result = formatClockCardDate(
|
||||
date,
|
||||
{
|
||||
parts: [
|
||||
"month-numeric",
|
||||
"separator-slash",
|
||||
"day-2-digit",
|
||||
"separator-dash",
|
||||
"year-2-digit",
|
||||
],
|
||||
},
|
||||
"en",
|
||||
"UTC"
|
||||
);
|
||||
|
||||
assert.equal(result, "11/08-24");
|
||||
});
|
||||
|
||||
it("uses separator only for the next gap", () => {
|
||||
const result = formatClockCardDate(
|
||||
date,
|
||||
{
|
||||
parts: [
|
||||
"day-numeric",
|
||||
"separator-dot",
|
||||
"month-numeric",
|
||||
"year-numeric",
|
||||
],
|
||||
},
|
||||
"en",
|
||||
"UTC"
|
||||
);
|
||||
|
||||
assert.equal(result, "8.11 2024");
|
||||
});
|
||||
|
||||
it("supports using the same separator style multiple times", () => {
|
||||
const result = formatClockCardDate(
|
||||
date,
|
||||
{
|
||||
parts: [
|
||||
"month-numeric",
|
||||
"separator-slash",
|
||||
"day-2-digit",
|
||||
"separator-slash",
|
||||
"year-2-digit",
|
||||
],
|
||||
},
|
||||
"en",
|
||||
"UTC"
|
||||
);
|
||||
|
||||
assert.equal(result, "11/08/24");
|
||||
});
|
||||
|
||||
it("renders separators even when no value follows", () => {
|
||||
const result = formatClockCardDate(
|
||||
date,
|
||||
{
|
||||
parts: ["day-numeric", "separator-dash", "separator-dot"],
|
||||
},
|
||||
"en",
|
||||
"UTC"
|
||||
);
|
||||
|
||||
assert.equal(result, "8-.");
|
||||
});
|
||||
|
||||
it("renders separators even when no value precedes", () => {
|
||||
const result = formatClockCardDate(
|
||||
date,
|
||||
{
|
||||
parts: ["separator-slash", "separator-dot", "day-numeric"],
|
||||
},
|
||||
"en",
|
||||
"UTC"
|
||||
);
|
||||
|
||||
assert.equal(result, "/.8");
|
||||
});
|
||||
|
||||
it("renders all consecutive separators between values", () => {
|
||||
const result = formatClockCardDate(
|
||||
date,
|
||||
{
|
||||
parts: [
|
||||
"day-numeric",
|
||||
"separator-dash",
|
||||
"separator-slash",
|
||||
"separator-dot",
|
||||
"month-numeric",
|
||||
],
|
||||
},
|
||||
"en",
|
||||
"UTC"
|
||||
);
|
||||
|
||||
assert.equal(result, "8-/.11");
|
||||
});
|
||||
|
||||
it("renders repeated separators without deduplication", () => {
|
||||
const result = formatClockCardDate(
|
||||
date,
|
||||
{
|
||||
parts: [
|
||||
"day-numeric",
|
||||
"separator-dash",
|
||||
"separator-dash",
|
||||
"separator-dash",
|
||||
"month-numeric",
|
||||
],
|
||||
},
|
||||
"en",
|
||||
"UTC"
|
||||
);
|
||||
|
||||
assert.equal(result, "8---11");
|
||||
});
|
||||
|
||||
it("renders separator-only configurations", () => {
|
||||
const result = formatClockCardDate(
|
||||
date,
|
||||
{
|
||||
parts: ["separator-dash", "separator-slash", "separator-dot"],
|
||||
},
|
||||
"en",
|
||||
"UTC"
|
||||
);
|
||||
|
||||
assert.equal(result, "-/.");
|
||||
});
|
||||
|
||||
it("supports inserting a new line between date values", () => {
|
||||
const result = formatClockCardDate(
|
||||
date,
|
||||
{
|
||||
parts: [
|
||||
"month-numeric",
|
||||
"separator-new-line",
|
||||
"day-2-digit",
|
||||
"year-numeric",
|
||||
],
|
||||
},
|
||||
"en",
|
||||
"UTC"
|
||||
);
|
||||
|
||||
assert.equal(result, "11\n08 2024");
|
||||
});
|
||||
|
||||
it("allows multiple variants for the same date part", () => {
|
||||
const result = formatClockCardDate(
|
||||
date,
|
||||
{
|
||||
parts: ["month-short", "month-long", "year-numeric"],
|
||||
},
|
||||
"en",
|
||||
"UTC"
|
||||
);
|
||||
|
||||
assert.equal(result, "Nov November 2024");
|
||||
});
|
||||
|
||||
it("filters invalid tokens when formatting", () => {
|
||||
const config = getClockCardDateConfig({
|
||||
date_format: [
|
||||
"month-numeric",
|
||||
"invalid",
|
||||
"year-numeric",
|
||||
] as unknown as ClockCardDatePart[],
|
||||
});
|
||||
|
||||
const result = formatClockCardDate(date, config, "en", "UTC");
|
||||
|
||||
assert.equal(result, "11 2024");
|
||||
});
|
||||
});
|
||||
@@ -5876,217 +5876,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript/native@npm:typescript@7.0.2":
|
||||
version: 7.0.2
|
||||
resolution: "typescript@npm:7.0.2"
|
||||
dependencies:
|
||||
"@typescript/typescript-aix-ppc64": "npm:7.0.2"
|
||||
"@typescript/typescript-darwin-arm64": "npm:7.0.2"
|
||||
"@typescript/typescript-darwin-x64": "npm:7.0.2"
|
||||
"@typescript/typescript-freebsd-arm64": "npm:7.0.2"
|
||||
"@typescript/typescript-freebsd-x64": "npm:7.0.2"
|
||||
"@typescript/typescript-linux-arm": "npm:7.0.2"
|
||||
"@typescript/typescript-linux-arm64": "npm:7.0.2"
|
||||
"@typescript/typescript-linux-loong64": "npm:7.0.2"
|
||||
"@typescript/typescript-linux-mips64el": "npm:7.0.2"
|
||||
"@typescript/typescript-linux-ppc64": "npm:7.0.2"
|
||||
"@typescript/typescript-linux-riscv64": "npm:7.0.2"
|
||||
"@typescript/typescript-linux-s390x": "npm:7.0.2"
|
||||
"@typescript/typescript-linux-x64": "npm:7.0.2"
|
||||
"@typescript/typescript-netbsd-arm64": "npm:7.0.2"
|
||||
"@typescript/typescript-netbsd-x64": "npm:7.0.2"
|
||||
"@typescript/typescript-openbsd-arm64": "npm:7.0.2"
|
||||
"@typescript/typescript-openbsd-x64": "npm:7.0.2"
|
||||
"@typescript/typescript-sunos-x64": "npm:7.0.2"
|
||||
"@typescript/typescript-win32-arm64": "npm:7.0.2"
|
||||
"@typescript/typescript-win32-x64": "npm:7.0.2"
|
||||
dependenciesMeta:
|
||||
"@typescript/typescript-aix-ppc64":
|
||||
optional: true
|
||||
"@typescript/typescript-darwin-arm64":
|
||||
optional: true
|
||||
"@typescript/typescript-darwin-x64":
|
||||
optional: true
|
||||
"@typescript/typescript-freebsd-arm64":
|
||||
optional: true
|
||||
"@typescript/typescript-freebsd-x64":
|
||||
optional: true
|
||||
"@typescript/typescript-linux-arm":
|
||||
optional: true
|
||||
"@typescript/typescript-linux-arm64":
|
||||
optional: true
|
||||
"@typescript/typescript-linux-loong64":
|
||||
optional: true
|
||||
"@typescript/typescript-linux-mips64el":
|
||||
optional: true
|
||||
"@typescript/typescript-linux-ppc64":
|
||||
optional: true
|
||||
"@typescript/typescript-linux-riscv64":
|
||||
optional: true
|
||||
"@typescript/typescript-linux-s390x":
|
||||
optional: true
|
||||
"@typescript/typescript-linux-x64":
|
||||
optional: true
|
||||
"@typescript/typescript-netbsd-arm64":
|
||||
optional: true
|
||||
"@typescript/typescript-netbsd-x64":
|
||||
optional: true
|
||||
"@typescript/typescript-openbsd-arm64":
|
||||
optional: true
|
||||
"@typescript/typescript-openbsd-x64":
|
||||
optional: true
|
||||
"@typescript/typescript-sunos-x64":
|
||||
optional: true
|
||||
"@typescript/typescript-win32-arm64":
|
||||
optional: true
|
||||
"@typescript/typescript-win32-x64":
|
||||
optional: true
|
||||
bin:
|
||||
tsc: bin/tsc
|
||||
checksum: 10/b0179005349b43dc1febb35c4e79162cdf89b84eae60f6deac142429109041e07582e69a6aacf5a897e085869686d4512ea444a10acf5b5aa2b60910f82a19ca
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript/typescript-aix-ppc64@npm:7.0.2":
|
||||
version: 7.0.2
|
||||
resolution: "@typescript/typescript-aix-ppc64@npm:7.0.2"
|
||||
conditions: os=aix & cpu=ppc64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript/typescript-darwin-arm64@npm:7.0.2":
|
||||
version: 7.0.2
|
||||
resolution: "@typescript/typescript-darwin-arm64@npm:7.0.2"
|
||||
conditions: os=darwin & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript/typescript-darwin-x64@npm:7.0.2":
|
||||
version: 7.0.2
|
||||
resolution: "@typescript/typescript-darwin-x64@npm:7.0.2"
|
||||
conditions: os=darwin & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript/typescript-freebsd-arm64@npm:7.0.2":
|
||||
version: 7.0.2
|
||||
resolution: "@typescript/typescript-freebsd-arm64@npm:7.0.2"
|
||||
conditions: os=freebsd & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript/typescript-freebsd-x64@npm:7.0.2":
|
||||
version: 7.0.2
|
||||
resolution: "@typescript/typescript-freebsd-x64@npm:7.0.2"
|
||||
conditions: os=freebsd & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript/typescript-linux-arm64@npm:7.0.2":
|
||||
version: 7.0.2
|
||||
resolution: "@typescript/typescript-linux-arm64@npm:7.0.2"
|
||||
conditions: os=linux & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript/typescript-linux-arm@npm:7.0.2":
|
||||
version: 7.0.2
|
||||
resolution: "@typescript/typescript-linux-arm@npm:7.0.2"
|
||||
conditions: os=linux & cpu=arm
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript/typescript-linux-loong64@npm:7.0.2":
|
||||
version: 7.0.2
|
||||
resolution: "@typescript/typescript-linux-loong64@npm:7.0.2"
|
||||
conditions: os=linux & cpu=loong64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript/typescript-linux-mips64el@npm:7.0.2":
|
||||
version: 7.0.2
|
||||
resolution: "@typescript/typescript-linux-mips64el@npm:7.0.2"
|
||||
conditions: os=linux & cpu=mips64el
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript/typescript-linux-ppc64@npm:7.0.2":
|
||||
version: 7.0.2
|
||||
resolution: "@typescript/typescript-linux-ppc64@npm:7.0.2"
|
||||
conditions: os=linux & cpu=ppc64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript/typescript-linux-riscv64@npm:7.0.2":
|
||||
version: 7.0.2
|
||||
resolution: "@typescript/typescript-linux-riscv64@npm:7.0.2"
|
||||
conditions: os=linux & cpu=riscv64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript/typescript-linux-s390x@npm:7.0.2":
|
||||
version: 7.0.2
|
||||
resolution: "@typescript/typescript-linux-s390x@npm:7.0.2"
|
||||
conditions: os=linux & cpu=s390x
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript/typescript-linux-x64@npm:7.0.2":
|
||||
version: 7.0.2
|
||||
resolution: "@typescript/typescript-linux-x64@npm:7.0.2"
|
||||
conditions: os=linux & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript/typescript-netbsd-arm64@npm:7.0.2":
|
||||
version: 7.0.2
|
||||
resolution: "@typescript/typescript-netbsd-arm64@npm:7.0.2"
|
||||
conditions: os=netbsd & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript/typescript-netbsd-x64@npm:7.0.2":
|
||||
version: 7.0.2
|
||||
resolution: "@typescript/typescript-netbsd-x64@npm:7.0.2"
|
||||
conditions: os=netbsd & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript/typescript-openbsd-arm64@npm:7.0.2":
|
||||
version: 7.0.2
|
||||
resolution: "@typescript/typescript-openbsd-arm64@npm:7.0.2"
|
||||
conditions: os=openbsd & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript/typescript-openbsd-x64@npm:7.0.2":
|
||||
version: 7.0.2
|
||||
resolution: "@typescript/typescript-openbsd-x64@npm:7.0.2"
|
||||
conditions: os=openbsd & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript/typescript-sunos-x64@npm:7.0.2":
|
||||
version: 7.0.2
|
||||
resolution: "@typescript/typescript-sunos-x64@npm:7.0.2"
|
||||
conditions: os=sunos & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript/typescript-win32-arm64@npm:7.0.2":
|
||||
version: 7.0.2
|
||||
resolution: "@typescript/typescript-win32-arm64@npm:7.0.2"
|
||||
conditions: os=win32 & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript/typescript-win32-x64@npm:7.0.2":
|
||||
version: 7.0.2
|
||||
resolution: "@typescript/typescript-win32-x64@npm:7.0.2"
|
||||
conditions: os=win32 & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@unrs/resolver-binding-android-arm-eabi@npm:1.12.2":
|
||||
version: 1.12.2
|
||||
resolution: "@unrs/resolver-binding-android-arm-eabi@npm:1.12.2"
|
||||
@@ -10003,7 +9792,6 @@ __metadata:
|
||||
"@types/qrcode": "npm:1.5.6"
|
||||
"@types/sortablejs": "npm:1.15.9"
|
||||
"@types/tar": "npm:7.0.87"
|
||||
"@typescript/native": "npm:typescript@7.0.2"
|
||||
"@vibrant/color": "npm:4.0.4"
|
||||
"@vitest/coverage-v8": "npm:4.1.10"
|
||||
"@vvo/tzdb": "npm:6.198.0"
|
||||
@@ -10084,7 +9872,7 @@ __metadata:
|
||||
sortablejs: "patch:sortablejs@npm%3A1.15.6#~/.yarn/patches/sortablejs-npm-1.15.6-3235a8f83b.patch"
|
||||
stacktrace-js: "npm:2.0.2"
|
||||
superstruct: "npm:2.0.2"
|
||||
tar: "npm:7.5.20"
|
||||
tar: "npm:7.5.19"
|
||||
terser-webpack-plugin: "npm:5.6.1"
|
||||
tinykeys: "patch:tinykeys@npm%3A4.0.0#~/.yarn/patches/tinykeys-npm-4.0.0-a6ca3fd771.patch"
|
||||
ts-lit-plugin: "npm:2.0.2"
|
||||
@@ -14639,16 +14427,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tar@npm:*, tar@npm:7.5.20, tar@npm:^7.4.3, tar@npm:^7.5.4":
|
||||
version: 7.5.20
|
||||
resolution: "tar@npm:7.5.20"
|
||||
"tar@npm:*, tar@npm:7.5.19, tar@npm:^7.4.3, tar@npm:^7.5.4":
|
||||
version: 7.5.19
|
||||
resolution: "tar@npm:7.5.19"
|
||||
dependencies:
|
||||
"@isaacs/fs-minipass": "npm:^4.0.0"
|
||||
chownr: "npm:^3.0.0"
|
||||
minipass: "npm:^7.1.2"
|
||||
minizlib: "npm:^3.1.0"
|
||||
yallist: "npm:^5.0.0"
|
||||
checksum: 10/90a0fe423ac921197ad5eefc5e5f7ad7f42b06e80444c8c347c1e4112384cbe9cb53ade3ef71748eed3684888756869c2aeef6b6303c766101f3217e254d4be9
|
||||
checksum: 10/0b06a0917fe68a4dff361e147db30fd67ae2ee85ab2863d62046a6ccef46f0d1906eed20f92277a436300eaaa0e3cd31d8763d7f02fa389f41d7966e58388db8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user