Compare commits

..

1 Commits

Author SHA1 Message Date
Paulus Schoutsen
9ecb41accd App info page no longer links to itself 2026-03-29 10:45:00 -04:00
53 changed files with 357 additions and 1789 deletions

View File

@@ -41,14 +41,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
uses: github/codeql-action/init@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
with:
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
uses: github/codeql-action/autobuild@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -62,4 +62,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
uses: github/codeql-action/analyze@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1

View File

@@ -82,7 +82,7 @@
"@mdi/js": "7.4.47",
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.20",
"@swc/helpers": "0.5.19",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "3.9.1",
"@tsparticles/preset-links": "3.2.0",
@@ -169,7 +169,7 @@
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.1.2",
"@vitest/coverage-v8": "4.1.1",
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
@@ -210,7 +210,7 @@
"typescript": "5.9.3",
"typescript-eslint": "8.57.2",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.2",
"vitest": "4.1.1",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.0#~/.yarn/patches/workbox-build-npm-7.4.0-c84561662c.patch"

View File

@@ -32,12 +32,6 @@ const YAML_ONLY_THEMES_COLORS = new Set([
"disabled",
]);
/**
* Compose a CSS variable out of a theme color
* @param color - Theme color (examples: `red`, `primary-text`)
* @returns CSS variable in `--xxx-color` format;
* initial color if not found in theme colors
*/
export function computeCssVariableName(color: string): string {
if (THEME_COLORS.has(color) || YAML_ONLY_THEMES_COLORS.has(color)) {
return `--${color}-color`;
@@ -45,12 +39,6 @@ export function computeCssVariableName(color: string): string {
return color;
}
/**
* Compose a CSS variable out of a theme color & then resolve it
* @param color - Theme color (examples: `red`, `primary-text`)
* @returns Resolved CSS variable in `var(--xxx-color)` format;
* initial color if not found in theme colors
*/
export function computeCssColor(color: string): string {
const cssVarName = computeCssVariableName(color);
if (cssVarName !== color) {
@@ -59,22 +47,6 @@ export function computeCssColor(color: string): string {
return color;
}
/**
* Get a color from document's styles
* @param color - Named theme color (examples: `red`, `primary-text`)
* @returns Resolved color; initial color if not found in document's styles
*/
export function resolveThemeColor(color: string): string {
const cssColor = computeCssVariableName(color);
if (cssColor.startsWith("--")) {
const resolved = getComputedStyle(document.body)
.getPropertyValue(cssColor)
.trim();
return resolved || color;
}
return cssColor;
}
/**
* Validates if a string is a valid color.
* Accepts: hex colors (#xxx, #xxxxxx), theme colors, and valid CSS color names.

View File

@@ -1,6 +1,5 @@
import colors from "color-name";
import { expandHex } from "./hex";
import { resolveThemeColor } from "./compute-color";
const rgb_hex = (component: number): string => {
const hex = Math.round(Math.min(Math.max(component, 0), 255)).toString(16);
@@ -131,43 +130,26 @@ export const rgb2hs = (rgb: [number, number, number]): [number, number] =>
export const hs2rgb = (hs: [number, number]): [number, number, number] =>
hsv2rgb([hs[0], hs[1], 255]);
/**
* Attempt to get a HEX color from a color defined in different formats:
* HEX, rgb/rgba, named color
* @param color - Color (HEX, rgb/rgba, named color) to be converted to HEX
* @returns HEX color
*/
export function theme2hex(color: string): string {
// Attempting to find a HEX pattern in the input string
if (color.startsWith("#")) {
if (color.length === 4 || color.length === 5) {
const c = color;
export function theme2hex(themeColor: string): string {
if (themeColor.startsWith("#")) {
if (themeColor.length === 4 || themeColor.length === 5) {
const c = themeColor;
// Convert short-form hex (#abc) to 6 digit (#aabbcc). Ignore alpha channel.
return `#${c[1]}${c[1]}${c[2]}${c[2]}${c[3]}${c[3]}`;
}
if (color.length === 9) {
if (themeColor.length === 9) {
// Ignore alpha channel.
return color.substring(0, 7);
return themeColor.substring(0, 7);
}
return color;
return themeColor;
}
// Attempting to find a match in a HA Frontend theme colors
const themeColor = resolveThemeColor(color.toLowerCase());
if (themeColor !== color.toLowerCase()) {
// theme color is recognized, now re-attempt
return theme2hex(themeColor);
const rgbFromColorName = colors[themeColor.toLowerCase()];
if (rgbFromColorName) {
return rgb2hex(rgbFromColorName);
}
// Attempting to find a match in a web colors array
const rgbFromWebColor = colors[color.toLowerCase()];
if (rgbFromWebColor) {
// HEX color is recognized for the input named color
return rgb2hex(rgbFromWebColor);
}
// Attempting to find an RGB pattern in the input string
const rgbMatch = color.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
const rgbMatch = themeColor.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (rgbMatch) {
const [, r, g, b] = rgbMatch.map(Number);
return rgb2hex([r, g, b]);
@@ -176,5 +158,5 @@ export function theme2hex(color: string): string {
// We have a named color, and there's nothing in the table,
// so nothing further we can do with it.
// Compare/border/background color will all be the same.
return color;
return themeColor;
}

View File

@@ -1,5 +1,4 @@
import { wcagLuminance, wcagContrast } from "culori";
import { theme2hex } from "./convert-color";
/**
* Calculates the luminosity of an RGB color.
@@ -49,13 +48,3 @@ export const getRGBContrastRatio = (
rgb1: [number, number, number],
rgb2: [number, number, number]
) => Math.round((rgbContrast(rgb1, rgb2) + Number.EPSILON) * 100) / 100;
/**
* Returns a contrasted color (black or white) based on the luminance of another color
* @param color - Color (HEX, rgb/rgba, named color) to calculate a contrasted color
* @returns HEX color ("#000000" for dark backgrounds, "#ffffff" for light backgrounds)
*/
export const getContrastedColorHex = (color: string): string => {
const lum = wcagLuminance(theme2hex(color));
return lum > 0.5 ? "#000000" : "#ffffff";
};

View File

@@ -5,41 +5,12 @@ import {
formatDateMonthYear,
formatDateVeryShort,
formatDateWeekdayShort,
formatDateYear,
} from "../../common/datetime/format_date";
import {
formatTime,
formatTimeWithSeconds,
} from "../../common/datetime/format_time";
export function getPeriodicAxisLabelConfig(
period: string,
locale: FrontendLocaleData,
config: HassConfig
):
| {
formatter: (value: number) => string;
}
| undefined {
if (period === "month") {
return {
formatter: (value: number) => {
const date = new Date(value);
return date.getMonth() === 0
? `{bold|${formatDateMonthYear(date, locale, config)}}`
: formatDateMonth(date, locale, config);
},
};
}
if (period === "year") {
return {
formatter: (value: number) =>
formatDateYear(new Date(value), locale, config),
};
}
return undefined;
}
export function formatTimeLabel(
value: number | Date,
locale: FrontendLocaleData,

View File

@@ -91,10 +91,6 @@ export class HaChartBase extends LitElement {
private _lastTapTime?: number;
private _longPressTimer?: ReturnType<typeof setTimeout>;
private _longPressTriggered = false;
private _shouldResizeChart = false;
private _resizeAnimationDuration?: number;
@@ -132,7 +128,6 @@ export class HaChartBase extends LitElement {
public disconnectedCallback() {
super.disconnectedCallback();
this._legendPointerCancel();
this._pendingSetup = false;
while (this._listeners.length) {
this._listeners.pop()!();
@@ -307,31 +302,22 @@ export class HaChartBase extends LitElement {
`;
}
private _getLegendItems() {
private _renderLegend() {
if (!this.options?.legend || !this.data) {
return undefined;
return nothing;
}
const legend = ensureArray(this.options.legend).find(
(l) => l.show && l.type === "custom"
) as CustomLegendOption | undefined;
if (!legend) {
return undefined;
return nothing;
}
const datasets = ensureArray(this.data);
return (
const items =
legend.data ||
datasets
.filter((d) => (d.data as any[])?.length && (d.id || d.name))
.map((d) => ({ id: d.id, name: d.name }))
);
}
private _renderLegend() {
const items = this._getLegendItems();
if (!items) {
return nothing;
}
const datasets = ensureArray(this.data!);
.map((d) => ({ id: d.id, name: d.name }));
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
@@ -376,11 +362,6 @@ export class HaChartBase extends LitElement {
return html`<li
.id=${id}
@click=${this._legendClick}
@pointerdown=${this._legendPointerDown}
@pointerup=${this._legendPointerCancel}
@pointerleave=${this._legendPointerCancel}
@pointercancel=${this._legendPointerCancel}
@contextmenu=${this._legendContextMenu}
class=${classMap({ hidden: this._hiddenDatasets.has(id) })}
.title=${name}
>
@@ -651,7 +632,7 @@ export class HaChartBase extends LitElement {
hideOverlap: true,
...axis.axisLabel,
},
minInterval: axis.minInterval ?? minInterval,
minInterval,
} as XAXisOption;
});
}
@@ -1041,52 +1022,11 @@ export class HaChartBase extends LitElement {
fireEvent(this, "chart-zoom", { start, end });
}
// Long-press to solo on touch/pen devices (500ms, consistent with action-handler-directive)
private _legendPointerDown(ev: PointerEvent) {
// Mouse uses Ctrl/Cmd+click instead
if (ev.pointerType === "mouse") {
return;
}
const id = (ev.currentTarget as HTMLElement)?.id;
if (!id) {
return;
}
this._longPressTriggered = false;
this._longPressTimer = setTimeout(() => {
this._longPressTriggered = true;
this._longPressTimer = undefined;
this._soloLegend(id);
}, 500);
}
private _legendPointerCancel() {
if (this._longPressTimer) {
clearTimeout(this._longPressTimer);
this._longPressTimer = undefined;
}
}
private _legendContextMenu(ev: Event) {
if (this._longPressTimer || this._longPressTriggered) {
ev.preventDefault();
}
}
private _legendClick(ev: MouseEvent) {
private _legendClick(ev: any) {
if (!this.chart) {
return;
}
if (this._longPressTriggered) {
this._longPressTriggered = false;
return;
}
const id = (ev.currentTarget as HTMLElement)?.id;
// Cmd+click on Mac (Ctrl+click is right-click there), Ctrl+click elsewhere
const soloModifier = isMac ? ev.metaKey : ev.ctrlKey;
if (soloModifier) {
this._soloLegend(id);
return;
}
const id = ev.currentTarget?.id;
if (this._hiddenDatasets.has(id)) {
this._getAllIdsFromLegend(this.options, id).forEach((i) =>
this._hiddenDatasets.delete(i)
@@ -1101,60 +1041,6 @@ export class HaChartBase extends LitElement {
this.requestUpdate("_hiddenDatasets");
}
private _soloLegend(id: string) {
const allIds = this._getAllLegendIds();
const clickedIds = this._getAllIdsFromLegend(this.options, id);
const otherIds = allIds.filter((i) => !clickedIds.includes(i));
const clickedIsOnlyVisible =
clickedIds.every((i) => !this._hiddenDatasets.has(i)) &&
otherIds.every((i) => this._hiddenDatasets.has(i));
if (clickedIsOnlyVisible) {
// Already solo'd on this item — restore all series to visible
for (const hiddenId of [...this._hiddenDatasets]) {
this._hiddenDatasets.delete(hiddenId);
fireEvent(this, "dataset-unhidden", { id: hiddenId });
}
} else {
// Solo: hide every other series, unhide clicked if it was hidden
for (const otherId of otherIds) {
if (!this._hiddenDatasets.has(otherId)) {
this._hiddenDatasets.add(otherId);
fireEvent(this, "dataset-hidden", { id: otherId });
}
}
for (const clickedId of clickedIds) {
if (this._hiddenDatasets.has(clickedId)) {
this._hiddenDatasets.delete(clickedId);
fireEvent(this, "dataset-unhidden", { id: clickedId });
}
}
}
this.requestUpdate("_hiddenDatasets");
}
private _getAllLegendIds(): string[] {
const items = this._getLegendItems();
if (!items) {
return [];
}
const allIds = new Set<string>();
for (const item of items) {
const primaryId =
typeof item === "string"
? item
: ((item.id as string) ?? (item.name as string) ?? "");
for (const expandedId of this._getAllIdsFromLegend(
this.options,
primaryId
)) {
allIds.add(expandedId);
}
}
return [...allIds];
}
private _toggleExpandedLegend() {
this.expandLegend = !this.expandLegend;
setTimeout(() => {

View File

@@ -65,8 +65,6 @@ export interface NetworkData {
categories?: { name: string; symbol: string }[];
}
const PHYSICS_DISABLE_THRESHOLD = 512;
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports
let GraphChart: typeof import("echarts/lib/chart/graph/install");
@@ -96,7 +94,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
@state() private _reducedMotion = false;
@state() private _physicsEnabled?: boolean;
@state() private _physicsEnabled = true;
@state() private _showLabels = true;
@@ -124,14 +122,6 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
];
}
protected willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
if (this._physicsEnabled === undefined && this.data?.nodes?.length > 1) {
this._physicsEnabled =
this.data.nodes.length <= PHYSICS_DISABLE_THRESHOLD;
}
}
protected render() {
if (!GraphChart || !this.data.nodes?.length) {
return nothing;
@@ -148,7 +138,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
.hass=${this.hass}
.data=${this._getSeries(
this.data,
this._physicsEnabled ?? false,
this._physicsEnabled,
this._reducedMotion,
this._showLabels,
isMobile,

View File

@@ -32,7 +32,6 @@ import {
} from "../../data/recorder";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import { getPeriodicAxisLabelConfig } from "./axis-label";
import type { CustomLegendOption } from "./ha-chart-base";
import "./ha-chart-base";
@@ -294,22 +293,6 @@ export class StatisticsChart extends LitElement {
type: "time",
min: startTime,
max: this.endTime,
...(this.period === "month" && {
minInterval: 28 * 24 * 3600 * 1000,
axisLabel: getPeriodicAxisLabelConfig(
"month",
this.hass.locale,
this.hass.config
),
}),
...(this.period === "year" && {
minInterval: 365 * 24 * 3600 * 1000,
axisLabel: getPeriodicAxisLabelConfig(
"year",
this.hass.locale,
this.hass.config
),
}),
},
{
id: "hiddenAxis",

View File

@@ -2,6 +2,7 @@ import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { computeCssColor } from "../../common/color/compute-color";
import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { stringCompare } from "../../common/string/compare";
@@ -52,15 +53,16 @@ class HaDataTableLabels extends LitElement {
}
private _renderLabel(label: LabelRegistryEntry, clickAction: boolean) {
const color = label?.color ? computeCssColor(label.color) : undefined;
return html`
<ha-label
dense
role="button"
tabindex="0"
.color=${label.color}
.item=${label}
@click=${clickAction ? this._labelClicked : undefined}
@keydown=${clickAction ? this._labelClicked : undefined}
style=${color ? `--color: ${color}` : ""}
.description=${label.description}
>
${label?.icon
@@ -100,6 +102,10 @@ class HaDataTableLabels extends LitElement {
position: fixed;
flex-wrap: nowrap;
}
ha-label {
--ha-label-background-color: var(--color, var(--grey-color));
--ha-label-background-opacity: 0.5;
}
.plus {
--ha-label-background-color: transparent;
border: 1px solid var(--divider-color);

View File

@@ -4,7 +4,7 @@ import type { ActionDetail } from "@material/mwc-list";
import { mdiCalendarToday } from "@mdi/js";
import "cally";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, queryAll, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { firstWeekdayIndex } from "../../common/datetime/first_weekday";
import {
formatCallyDateRange,
@@ -29,7 +29,6 @@ import "../ha-list-item";
import "../ha-time-input";
import type { DateRangePickerRanges } from "./ha-date-range-picker";
import { datePickerStyles, dateRangePickerStyles } from "./styles";
import type { HaTimeInput } from "../ha-time-input";
@customElement("date-range-picker")
export class DateRangePicker extends LitElement {
@@ -70,8 +69,6 @@ export class DateRangePicker extends LitElement {
to: { hours: 23, minutes: 59 },
};
@queryAll("ha-time-input") private _timeInputs?: NodeListOf<HaTimeInput>;
public connectedCallback() {
super.connectedCallback();
@@ -156,7 +153,6 @@ export class DateRangePicker extends LitElement {
)}
id="from"
placeholder-labels
auto-validate
></ha-time-input>
<ha-time-input
.value=${`${this._timeValue.to.hours}:${this._timeValue.to.minutes}`}
@@ -167,7 +163,6 @@ export class DateRangePicker extends LitElement {
)}
id="to"
placeholder-labels
auto-validate
></ha-time-input>
</div>
`
@@ -205,14 +200,6 @@ export class DateRangePicker extends LitElement {
let endDate = new Date(`${dates[1]}T23:59:00`);
if (this.timePicker) {
const timeInputs = this._timeInputs;
if (
timeInputs &&
![...timeInputs].every((input) => input.reportValidity())
) {
// If we have time inputs, and they don't all report valid, don't save
return;
}
startDate.setHours(this._timeValue.from.hours);
startDate.setMinutes(this._timeValue.from.minutes);
endDate.setHours(this._timeValue.to.hours);
@@ -274,6 +261,12 @@ export class DateRangePicker extends LitElement {
const dateRange: [Date, Date] = Object.values(this.ranges!)[
ev.detail.index
];
this._dateValue = formatCallyDateRange(
dateRange[0],
dateRange[1],
this.locale,
this.hassConfig
);
fireEvent(this, "value-changed", {
value: {
startDate: dateRange[0],
@@ -288,8 +281,7 @@ export class DateRangePicker extends LitElement {
private _handleChangeTime(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const time = ev.detail.value;
const target = ev.target as HaBaseTimeInput;
const type = target.id;
const type = (ev.target as HaBaseTimeInput).id;
if (time) {
if (!this._timeValue) {
this._timeValue = {
@@ -309,39 +301,17 @@ export class DateRangePicker extends LitElement {
css`
.picker {
display: flex;
flex-direction: row;
}
.date-range-ranges {
border-right: 1px solid var(--divider-color);
min-width: 140px;
flex: 0 1 30%;
}
.range {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
padding: var(--ha-space-3);
overflow-x: hidden;
}
@media only screen and (max-width: 460px) {
.picker {
flex-direction: column;
}
.date-range-ranges {
flex-basis: 180px;
border-bottom: 1px solid var(--divider-color);
border-right: none;
overflow-y: scroll;
}
.range {
flex-basis: fit-content;
}
}
.times {
@@ -356,6 +326,12 @@ export class DateRangePicker extends LitElement {
padding: var(--ha-space-2);
border-top: 1px solid var(--divider-color);
}
@media only screen and (max-width: 500px) {
.date-range-ranges {
max-width: 30%;
}
}
`,
];
}

View File

@@ -80,6 +80,33 @@ export const datePickerStyles = css`
text-align: center;
margin-left: 48px;
}
@media only screen and (max-width: 500px) {
calendar-month {
min-height: calc(34px * 7);
}
calendar-month::part(day) {
font-size: var(--ha-font-size-s);
}
calendar-month::part(button) {
height: 26px;
width: 26px;
}
calendar-month::part(range-inner),
calendar-month::part(range-start),
calendar-month::part(range-end),
calendar-month::part(selected),
calendar-month::part(selected):hover {
height: 34px;
width: 34px;
}
.heading {
font-size: var(--ha-font-size-s);
}
.month-year {
margin-left: 40px;
}
}
`;
export const dateRangePickerStyles = css`

View File

@@ -137,7 +137,7 @@ export class HaBaseTimeInput extends LitElement {
@property({ attribute: "placeholder-labels", type: Boolean })
public placeholderLabels = false;
@queryAll("ha-input") private _inputs?: NodeListOf<HaInput>;
@queryAll("ha-input") private _inputs?: HaInput[];
static shadowRootOptions = {
...LitElement.shadowRootOptions,
@@ -145,9 +145,7 @@ export class HaBaseTimeInput extends LitElement {
};
public reportValidity(): boolean {
const inputs = this._inputs;
if (!inputs) return true;
return [...inputs].every((input) => input.reportValidity());
return this._inputs?.every((input) => input.reportValidity()) ?? true;
}
protected render(): TemplateResult {
@@ -401,7 +399,7 @@ export class HaBaseTimeInput extends LitElement {
.time-separator,
ha-icon-button {
background-color: var(--ha-color-form-background);
background-color: var(--ha-color-fill-neutral-quiet-resting);
color: var(--ha-color-text-secondary);
border-bottom: 1px solid var(--ha-color-border-neutral-loud);
box-sizing: border-box;

View File

@@ -100,9 +100,6 @@ export class HaDropdown extends Dropdown {
#menu {
padding: var(--ha-space-1);
}
wa-popup::part(popup) {
z-index: 200;
}
`,
];
}

View File

@@ -6,6 +6,7 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../common/color/compute-color";
import { fireEvent } from "../common/dom/fire_event";
import { navigate } from "../common/navigate";
import { stringCompare } from "../common/string/compare";
@@ -97,14 +98,17 @@ export class HaFilterLabels extends LitElement {
this.value
),
(label) => label.label_id,
(label) =>
html`<ha-check-list-item
(label) => {
const color = label.color
? computeCssColor(label.color)
: undefined;
return html`<ha-check-list-item
.value=${label.label_id}
.selected=${(this.value || []).includes(label.label_id)}
hasMeta
>
<ha-label
.color=${label.color}
style=${color ? `--color: ${color}` : ""}
.description=${label.description}
>
${label.icon
@@ -115,7 +119,8 @@ export class HaFilterLabels extends LitElement {
: nothing}
${label.name}
</ha-label>
</ha-check-list-item>`
</ha-check-list-item>`;
}
)}
</ha-list> `
: nothing}
@@ -248,6 +253,10 @@ export class HaFilterLabels extends LitElement {
.warning {
color: var(--error-color);
}
ha-label {
--ha-label-background-color: var(--color, var(--grey-color));
--ha-label-background-opacity: 0.5;
}
.add {
position: absolute;
bottom: 0;

View File

@@ -1,44 +1,18 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { computeCssColor } from "../common/color/compute-color";
import { getContrastedColorHex } from "../common/color/rgb";
import { uid } from "../common/util/uid";
import "./ha-tooltip";
/**
* Returns CSS styles for a label's background & icon/text
* @param color Label color defined in HEX format
* @returns CSS styles
*/
export const getLabelColorStyle = (labelColor: string | undefined | null) => {
const color = labelColor ? computeCssColor(labelColor) : undefined;
return color
? `--ha-label-background-color: ${color};
--primary-text-color: ${getContrastedColorHex(labelColor!)};`
: `--ha-label-background-color: rgba(var(--rgb-primary-text-color), 0.15);`;
};
@customElement("ha-label")
class HaLabel extends LitElement {
@property({ type: Boolean, reflect: true }) dense = false;
@property()
public color?: string;
@property({ attribute: "description" })
public description?: string;
private _elementId = "label-" + uid();
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (!changedProps.has("color")) {
return;
}
this.style.cssText = getLabelColorStyle(this.color);
}
protected render(): TemplateResult {
return html`
<ha-tooltip
@@ -62,6 +36,10 @@ class HaLabel extends LitElement {
:host {
--ha-label-text-color: var(--primary-text-color);
--ha-label-icon-color: var(--primary-text-color);
--ha-label-background-color: rgba(
var(--rgb-primary-text-color),
0.15
);
--ha-label-background-opacity: 1;
border: 1px solid var(--outline-color);
position: relative;

View File

@@ -6,6 +6,7 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../common/color/compute-color";
import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
import { labelsContext } from "../data/context";
@@ -16,7 +17,6 @@ import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./chips/ha-chip-set";
import "./chips/ha-input-chip";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import { getLabelColorStyle } from "./ha-label";
import "./ha-label-picker";
import type { HaLabelPicker } from "./ha-label-picker";
import "./ha-tooltip";
@@ -106,14 +106,9 @@ export class HaLabelsPicker extends LitElement {
labels?.find((label) => label.label_id === id) || {
label_id: id,
name: id,
color: "rgba(var(--rgb-primary-text-color), 0.15)",
}
)
.sort((a, b) => stringCompare(a?.name || "", b?.name || "", language))
.map((label) => ({
...label,
style: getLabelColorStyle(label.color),
}))
);
protected render(): TemplateResult {
@@ -140,6 +135,9 @@ export class HaLabelsPicker extends LitElement {
(label) => label?.label_id,
(label) => {
if (!label) return nothing;
const color = label.color
? computeCssColor(label.color)
: undefined;
const elementId = "label-" + label.label_id;
return html`
<ha-tooltip
@@ -156,7 +154,7 @@ export class HaLabelsPicker extends LitElement {
.disabled=${this.disabled}
.label=${label.name}
selected
style=${label.style}
style=${color ? `--color: ${color}` : ""}
>
${label.icon
? html`<ha-icon
@@ -241,10 +239,8 @@ export class HaLabelsPicker extends LitElement {
height: var(--ha-space-8);
}
ha-input-chip {
--md-input-chip-selected-container-color: var(
--ha-label-background-color,
var(--grey-color)
);
--md-input-chip-selected-container-color: var(--color, var(--grey-color));
--ha-input-chip-selected-container-opacity: 0.5;
--md-input-chip-selected-outline-width: 1px;
}
label {

View File

@@ -153,7 +153,10 @@ export class HaPickerField extends PickerMixin(LitElement) {
right: 0;
height: 1px;
width: 100%;
background-color: var(--ha-color-border-neutral-loud);
background-color: var(
--mdc-text-field-idle-line-color,
rgba(0, 0, 0, 0.42)
);
transform:
height 180ms ease-in-out,
background-color 180ms ease-in-out;

View File

@@ -1,310 +0,0 @@
export const RETRO_THEME = {
// Sharp corners
"ha-border-radius-sm": "0",
"ha-border-radius-md": "0",
"ha-border-radius-lg": "0",
"ha-border-radius-xl": "0",
"ha-border-radius-2xl": "0",
"ha-border-radius-3xl": "0",
"ha-border-radius-4xl": "0",
"ha-border-radius-5xl": "0",
"ha-border-radius-6xl": "0",
"ha-border-radius-pill": "0",
"ha-border-radius-circle": "0",
// Fonts
"ha-font-family-body":
"Tahoma, 'MS Sans Serif', 'Microsoft Sans Serif', Arial, sans-serif",
"ha-font-family-heading":
"Tahoma, 'MS Sans Serif', 'Microsoft Sans Serif', Arial, sans-serif",
"ha-font-family-code": "'Courier New', Courier, monospace",
"ha-font-family-longform":
"Tahoma, 'MS Sans Serif', 'Microsoft Sans Serif', Arial, sans-serif",
// No transparency
"ha-dialog-scrim-backdrop-filter": "none",
// Disable animations
"ha-animation-duration-fast": "1ms",
"ha-animation-duration-normal": "1ms",
"ha-animation-duration-slow": "1ms",
modes: {
light: {
// Base colors
"primary-color": "#000080",
"dark-primary-color": "#00006B",
"light-primary-color": "#4040C0",
"accent-color": "#000080",
"primary-text-color": "#000000",
"secondary-text-color": "#404040",
"text-primary-color": "#ffffff",
"text-light-primary-color": "#000000",
"disabled-text-color": "#808080",
// Backgrounds
"primary-background-color": "#C0C0C0",
"lovelace-background": "#008080",
"secondary-background-color": "#C0C0C0",
"card-background-color": "#C0C0C0",
"clear-background-color": "#C0C0C0",
// RGB values
"rgb-primary-color": "0, 0, 128",
"rgb-accent-color": "0, 0, 128",
"rgb-primary-text-color": "0, 0, 0",
"rgb-secondary-text-color": "64, 64, 64",
"rgb-text-primary-color": "255, 255, 255",
"rgb-card-background-color": "192, 192, 192",
// UI chrome
"divider-color": "#808080",
"outline-color": "#808080",
"outline-hover-color": "#404040",
"shadow-color": "rgba(0, 0, 0, 0.5)",
"scrollbar-thumb-color": "#808080",
"disabled-color": "#808080",
// Cards - retro bevel effect
"ha-card-border-width": "1px",
"ha-card-border-color": "#808080",
"ha-card-box-shadow": "1px 1px 0 #404040, -1px -1px 0 #ffffff",
"ha-card-border-radius": "0",
// Dialogs
"ha-dialog-border-radius": "0",
"ha-dialog-surface-background": "#C0C0C0",
"dialog-box-shadow": "1px 1px 0 #404040, -1px -1px 0 #ffffff",
// Box shadows - retro bevel
"ha-box-shadow-s": "1px 1px 0 #404040, -1px -1px 0 #ffffff",
"ha-box-shadow-m": "1px 1px 0 #404040, -1px -1px 0 #ffffff",
"ha-box-shadow-l": "1px 1px 0 #404040, -1px -1px 0 #ffffff",
// Header
"app-header-background-color": "#000080",
"app-header-text-color": "#ffffff",
"app-header-border-bottom": "2px outset #C0C0C0",
// Sidebar
"sidebar-background-color": "#C0C0C0",
"sidebar-text-color": "#000000",
"sidebar-selected-text-color": "#ffffff",
"sidebar-selected-icon-color": "#000080",
"sidebar-icon-color": "#000000",
// Input
"input-fill-color": "#C0C0C0",
"input-disabled-fill-color": "#C0C0C0",
"input-ink-color": "#000000",
"input-label-ink-color": "#000000",
"input-disabled-ink-color": "#808080",
"input-idle-line-color": "#808080",
"input-hover-line-color": "#000000",
"input-disabled-line-color": "#808080",
"input-outlined-idle-border-color": "#808080",
"input-outlined-hover-border-color": "#000000",
"input-outlined-disabled-border-color": "#C0C0C0",
"input-dropdown-icon-color": "#000000",
// Status colors
"error-color": "#FF0000",
"warning-color": "#FF8000",
"success-color": "#008000",
"info-color": "#000080",
// State
"state-icon-color": "#000080",
"state-active-color": "#000080",
"state-inactive-color": "#808080",
// Data table
"data-table-border-width": "0",
// Primary scale
"ha-color-primary-05": "#00003A",
"ha-color-primary-10": "#000050",
"ha-color-primary-20": "#000066",
"ha-color-primary-30": "#00007A",
"ha-color-primary-40": "#000080",
"ha-color-primary-50": "#0000AA",
"ha-color-primary-60": "#4040C0",
"ha-color-primary-70": "#6060D0",
"ha-color-primary-80": "#8080E0",
"ha-color-primary-90": "#C8C8D8",
"ha-color-primary-95": "#D8D8E0",
// Neutral scale
"ha-color-neutral-05": "#000000",
"ha-color-neutral-10": "#2A2A2A",
"ha-color-neutral-20": "#404040",
"ha-color-neutral-30": "#606060",
"ha-color-neutral-40": "#707070",
"ha-color-neutral-50": "#808080",
"ha-color-neutral-60": "#909090",
"ha-color-neutral-70": "#A0A0A0",
"ha-color-neutral-80": "#B0B0B0",
"ha-color-neutral-90": "#C8C8C8",
"ha-color-neutral-95": "#D0D0D0",
// Codemirror
"codemirror-keyword": "#000080",
"codemirror-operator": "#000000",
"codemirror-variable": "#008080",
"codemirror-variable-2": "#000080",
"codemirror-variable-3": "#808000",
"codemirror-builtin": "#800080",
"codemirror-atom": "#008080",
"codemirror-number": "#FF0000",
"codemirror-def": "#000080",
"codemirror-string": "#008000",
"codemirror-string-2": "#808000",
"codemirror-comment": "#808080",
"codemirror-tag": "#800000",
"codemirror-meta": "#000080",
"codemirror-attribute": "#FF0000",
"codemirror-property": "#000080",
"codemirror-qualifier": "#808000",
"codemirror-type": "#000080",
},
dark: {
// Base colors
"primary-color": "#4040C0",
"dark-primary-color": "#000080",
"light-primary-color": "#6060D0",
"accent-color": "#4040C0",
"primary-text-color": "#C0C0C0",
"secondary-text-color": "#A0A0A0",
"text-primary-color": "#ffffff",
"text-light-primary-color": "#C0C0C0",
"disabled-text-color": "#606060",
// Backgrounds
"primary-background-color": "#2A2A2A",
"lovelace-background": "#003030",
"secondary-background-color": "#2A2A2A",
"card-background-color": "#3A3A3A",
"clear-background-color": "#2A2A2A",
// RGB values
"rgb-primary-color": "64, 64, 192",
"rgb-accent-color": "64, 64, 192",
"rgb-primary-text-color": "192, 192, 192",
"rgb-secondary-text-color": "160, 160, 160",
"rgb-text-primary-color": "255, 255, 255",
"rgb-card-background-color": "58, 58, 58",
// UI chrome
"divider-color": "#606060",
"outline-color": "#606060",
"outline-hover-color": "#808080",
"shadow-color": "rgba(0, 0, 0, 0.7)",
"scrollbar-thumb-color": "#606060",
"disabled-color": "#606060",
// Cards - retro bevel effect
"ha-card-border-width": "1px",
"ha-card-border-color": "#606060",
"ha-card-box-shadow": "1px 1px 0 #1A1A1A, -1px -1px 0 #5A5A5A",
"ha-card-border-radius": "0",
// Dialogs
"ha-dialog-border-radius": "0",
"ha-dialog-surface-background": "#3A3A3A",
"dialog-box-shadow": "1px 1px 0 #1A1A1A, -1px -1px 0 #5A5A5A",
// Box shadows - retro bevel
"ha-box-shadow-s": "1px 1px 0 #1A1A1A, -1px -1px 0 #5A5A5A",
"ha-box-shadow-m": "1px 1px 0 #1A1A1A, -1px -1px 0 #5A5A5A",
"ha-box-shadow-l": "1px 1px 0 #1A1A1A, -1px -1px 0 #5A5A5A",
// Header
"app-header-background-color": "#000060",
"app-header-text-color": "#ffffff",
"app-header-border-bottom": "2px outset #3A3A3A",
// Sidebar
"sidebar-background-color": "#2A2A2A",
"sidebar-text-color": "#C0C0C0",
"sidebar-selected-text-color": "#ffffff",
"sidebar-selected-icon-color": "#4040C0",
"sidebar-icon-color": "#A0A0A0",
// Input
"input-fill-color": "#3A3A3A",
"input-disabled-fill-color": "#3A3A3A",
"input-ink-color": "#C0C0C0",
"input-label-ink-color": "#A0A0A0",
"input-disabled-ink-color": "#606060",
"input-idle-line-color": "#606060",
"input-hover-line-color": "#808080",
"input-disabled-line-color": "#404040",
"input-outlined-idle-border-color": "#606060",
"input-outlined-hover-border-color": "#808080",
"input-outlined-disabled-border-color": "#404040",
"input-dropdown-icon-color": "#A0A0A0",
// Status colors
"error-color": "#FF4040",
"warning-color": "#FFA040",
"success-color": "#40C040",
"info-color": "#4040C0",
// State
"state-icon-color": "#4040C0",
"state-active-color": "#4040C0",
"state-inactive-color": "#606060",
// Data table
"data-table-border-width": "0",
// Primary scale
"ha-color-primary-05": "#00002A",
"ha-color-primary-10": "#000040",
"ha-color-primary-20": "#000060",
"ha-color-primary-30": "#000080",
"ha-color-primary-40": "#4040C0",
"ha-color-primary-50": "#6060D0",
"ha-color-primary-60": "#8080E0",
"ha-color-primary-70": "#A0A0F0",
"ha-color-primary-80": "#C0C0FF",
"ha-color-primary-90": "#3A3A58",
"ha-color-primary-95": "#303048",
// Neutral scale
"ha-color-neutral-05": "#1A1A1A",
"ha-color-neutral-10": "#2A2A2A",
"ha-color-neutral-20": "#3A3A3A",
"ha-color-neutral-30": "#4A4A4A",
"ha-color-neutral-40": "#606060",
"ha-color-neutral-50": "#707070",
"ha-color-neutral-60": "#808080",
"ha-color-neutral-70": "#909090",
"ha-color-neutral-80": "#A0A0A0",
"ha-color-neutral-90": "#C0C0C0",
"ha-color-neutral-95": "#D0D0D0",
// Codemirror
"codemirror-keyword": "#8080E0",
"codemirror-operator": "#C0C0C0",
"codemirror-variable": "#40C0C0",
"codemirror-variable-2": "#8080E0",
"codemirror-variable-3": "#C0C040",
"codemirror-builtin": "#C040C0",
"codemirror-atom": "#40C0C0",
"codemirror-number": "#FF6060",
"codemirror-def": "#8080E0",
"codemirror-string": "#40C040",
"codemirror-string-2": "#C0C040",
"codemirror-comment": "#808080",
"codemirror-tag": "#C04040",
"codemirror-meta": "#8080E0",
"codemirror-attribute": "#FF6060",
"codemirror-property": "#8080E0",
"codemirror-qualifier": "#C0C040",
"codemirror-type": "#8080E0",
"map-filter":
"invert(0.9) hue-rotate(170deg) brightness(1.5) contrast(1.2) saturate(0.3)",
},
},
};

View File

@@ -1,683 +0,0 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import {
applyThemesOnElement,
invalidateThemeCache,
} from "../common/dom/apply_themes_on_element";
import type { LocalizeKeys } from "../common/translations/localize";
import { subscribeLabFeature } from "../data/labs";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import type { HomeAssistant } from "../types";
import { RETRO_THEME } from "./ha-retro-theme";
const TIP_COUNT = 25;
type CasitaExpression =
| "hi"
| "ok-nabu"
| "heart"
| "sleep"
| "great-job"
| "error";
const STORAGE_KEY = "retro-position";
const DRAG_THRESHOLD = 5;
const BUBBLE_TIMEOUT = 8000;
const SLEEP_TIMEOUT = 30000;
const BSOD_CLICK_COUNT = 5;
const BSOD_CLICK_TIMEOUT = 3000;
const BSOD_DISMISS_DELAY = 500;
@customElement("ha-retro")
export class HaRetro extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@state() private _enabled = false;
public hassSubscribe() {
return [
subscribeLabFeature(
this.hass!.connection,
"frontend",
"retro",
(feature) => {
this._enabled = feature.enabled;
}
),
];
}
@state() private _casitaVisible = true;
@state() private _showBubble = false;
@state() private _bubbleText = "";
@state() private _expression: CasitaExpression = "hi";
@state() private _position: { x: number; y: number } | null = null;
@state() private _showBsod = false;
private _clickCount = 0;
private _clickTimer?: ReturnType<typeof setTimeout>;
private _dragging = false;
private _dragStartX = 0;
private _dragStartY = 0;
private _dragOffsetX = 0;
private _dragOffsetY = 0;
private _dragMoved = false;
private _bubbleTimer?: ReturnType<typeof setTimeout>;
private _sleepTimer?: ReturnType<typeof setTimeout>;
private _boundPointerMove = this._onPointerMove.bind(this);
private _boundPointerUp = this._onPointerUp.bind(this);
private _themeApplied = false;
private _isApplyingTheme = false;
private _themeObserver?: MutationObserver;
connectedCallback(): void {
super.connectedCallback();
this._loadPosition();
this._resetSleepTimer();
this._applyRetroTheme();
this._startThemeObserver();
}
disconnectedCallback(): void {
super.disconnectedCallback();
this._clearTimers();
this._stopThemeObserver();
this._revertTheme();
document.removeEventListener("pointermove", this._boundPointerMove);
document.removeEventListener("pointerup", this._boundPointerUp);
document.removeEventListener("keydown", this._boundDismissBsod);
}
protected willUpdate(changedProps: Map<string, unknown>): void {
if (changedProps.has("_enabled")) {
if (this._enabled) {
this.hass!.loadFragmentTranslation("retro");
this._applyRetroTheme();
this._startThemeObserver();
} else {
this._stopThemeObserver();
this._revertTheme();
}
}
if (changedProps.has("hass") && this._enabled) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
// Re-apply if darkMode changed
if (oldHass && oldHass.themes.darkMode !== this.hass!.themes.darkMode) {
this._themeApplied = false;
this._applyRetroTheme();
}
}
}
private _startThemeObserver(): void {
if (this._themeObserver) return;
this._themeObserver = new MutationObserver(() => {
if (this._isApplyingTheme || !this._enabled || !this.hass) return;
// Check if our theme was overwritten by the themes mixin
const el = document.documentElement as HTMLElement & {
__themes?: { cacheKey?: string };
};
if (!el.__themes?.cacheKey?.startsWith("Retro")) {
this._themeApplied = false;
this._applyRetroTheme();
}
});
this._themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["style"],
});
}
private _stopThemeObserver(): void {
this._themeObserver?.disconnect();
this._themeObserver = undefined;
}
private _applyRetroTheme(): void {
if (!this.hass || this._themeApplied) return;
this._isApplyingTheme = true;
const themes = {
...this.hass.themes,
themes: {
...this.hass.themes.themes,
Retro: RETRO_THEME,
},
};
invalidateThemeCache();
applyThemesOnElement(
document.documentElement,
themes,
"Retro",
{ dark: this.hass.themes.darkMode },
true
);
this._themeApplied = true;
this._isApplyingTheme = false;
}
private _revertTheme(): void {
if (!this.hass || !this._themeApplied) return;
this._isApplyingTheme = true;
invalidateThemeCache();
applyThemesOnElement(
document.documentElement,
this.hass.themes,
this.hass.selectedTheme?.theme || "default",
{
dark: this.hass.themes.darkMode,
primaryColor: this.hass.selectedTheme?.primaryColor,
accentColor: this.hass.selectedTheme?.accentColor,
},
true
);
this._themeApplied = false;
this._isApplyingTheme = false;
}
private _loadPosition(): void {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const pos = JSON.parse(stored);
if (typeof pos.x === "number" && typeof pos.y === "number") {
this._position = this._clampPosition(pos.x, pos.y);
}
}
} catch {
// Ignore invalid stored position
}
}
private _savePosition(): void {
if (this._position) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this._position));
} catch {
// Ignore storage errors
}
}
}
private _clampPosition(x: number, y: number): { x: number; y: number } {
const size = 80;
return {
x: Math.max(0, Math.min(window.innerWidth - size, x)),
y: Math.max(0, Math.min(window.innerHeight - size, y)),
};
}
private _onPointerDown(ev: PointerEvent): void {
if (ev.button !== 0 || this._showBsod) return;
this._dragging = true;
this._dragMoved = false;
this._dragStartX = ev.clientX;
this._dragStartY = ev.clientY;
const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect();
this._dragOffsetX = ev.clientX - rect.left;
this._dragOffsetY = ev.clientY - rect.top;
(ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId);
document.addEventListener("pointermove", this._boundPointerMove);
document.addEventListener("pointerup", this._boundPointerUp);
ev.preventDefault();
}
private _onPointerMove(ev: PointerEvent): void {
if (!this._dragging) return;
const dx = ev.clientX - this._dragStartX;
const dy = ev.clientY - this._dragStartY;
if (!this._dragMoved && Math.hypot(dx, dy) < DRAG_THRESHOLD) {
return;
}
this._dragMoved = true;
const x = ev.clientX - this._dragOffsetX;
const y = ev.clientY - this._dragOffsetY;
this._position = this._clampPosition(x, y);
}
private _onPointerUp(ev: PointerEvent): void {
document.removeEventListener("pointermove", this._boundPointerMove);
document.removeEventListener("pointerup", this._boundPointerUp);
this._dragging = false;
if (this._dragMoved) {
this._savePosition();
} else {
this._toggleBubble();
}
ev.preventDefault();
}
private _stopPropagation(ev: Event): void {
ev.stopPropagation();
}
private _dismiss(ev: Event): void {
ev.stopPropagation();
this._casitaVisible = false;
this._clearTimers();
}
private _toggleBubble(): void {
this._clickCount++;
if (this._clickTimer) {
clearTimeout(this._clickTimer);
}
this._clickTimer = setTimeout(() => {
this._clickCount = 0;
}, BSOD_CLICK_TIMEOUT);
if (this._clickCount >= BSOD_CLICK_COUNT) {
this._clickCount = 0;
this._triggerBsod();
return;
}
if (this._showBubble) {
this._hideBubble();
} else {
this._showTip();
}
}
private _boundDismissBsod = this._dismissBsodOnKey.bind(this);
private _bsodReadyToDismiss = false;
private _triggerBsod(): void {
this._hideBubble();
this._showBsod = true;
this._bsodReadyToDismiss = false;
this._expression = "error";
// Delay enabling dismiss so the rapid clicks that triggered the BSOD don't immediately close it
setTimeout(() => {
this._bsodReadyToDismiss = true;
document.addEventListener("keydown", this._boundDismissBsod);
}, BSOD_DISMISS_DELAY);
}
private _dismissBsod(): void {
if (!this._bsodReadyToDismiss) return;
this._showBsod = false;
this._expression = "hi";
this._resetSleepTimer();
document.removeEventListener("keydown", this._boundDismissBsod);
}
private _dismissBsodOnKey(): void {
this._dismissBsod();
}
private _showTip(): void {
const tipIndex = Math.floor(Math.random() * TIP_COUNT) + 1;
this._bubbleText = this.hass!.localize(
`ui.panel.retro.tip_${tipIndex}` as LocalizeKeys
);
this._showBubble = true;
this._expression = "ok-nabu";
this._resetSleepTimer();
if (this._bubbleTimer) {
clearTimeout(this._bubbleTimer);
}
this._bubbleTimer = setTimeout(() => {
this._hideBubble();
}, BUBBLE_TIMEOUT);
}
private _hideBubble(): void {
this._showBubble = false;
this._expression = "hi";
this._resetSleepTimer();
if (this._bubbleTimer) {
clearTimeout(this._bubbleTimer);
this._bubbleTimer = undefined;
}
}
private _closeBubble(ev: Event): void {
ev.stopPropagation();
this._hideBubble();
}
private _resetSleepTimer(): void {
if (this._sleepTimer) {
clearTimeout(this._sleepTimer);
}
this._sleepTimer = setTimeout(() => {
if (!this._showBubble) {
this._expression = "sleep";
}
}, SLEEP_TIMEOUT);
}
private _clearTimers(): void {
if (this._bubbleTimer) {
clearTimeout(this._bubbleTimer);
this._bubbleTimer = undefined;
}
if (this._sleepTimer) {
clearTimeout(this._sleepTimer);
this._sleepTimer = undefined;
}
if (this._clickTimer) {
clearTimeout(this._clickTimer);
this._clickTimer = undefined;
}
}
protected render() {
if (!this._enabled || !this._casitaVisible) {
return nothing;
}
const size = 80;
const posStyle = this._position
? `left: ${this._position.x}px; top: ${this._position.y}px;`
: `right: 16px; bottom: 16px;`;
return html`
${this._showBsod
? html`
<div class="bsod" @click=${this._dismissBsod}>
<div class="bsod-content">
<h1 class="bsod-title">
${this.hass!.localize("ui.panel.retro.bsod_title")}
</h1>
<p>${this.hass!.localize("ui.panel.retro.bsod_error")}</p>
<p>
* ${this.hass!.localize("ui.panel.retro.bsod_line_1")}<br />
* ${this.hass!.localize("ui.panel.retro.bsod_line_2")}
</p>
<p class="bsod-prompt">
${this.hass!.localize("ui.panel.retro.bsod_continue")}
<span class="bsod-cursor">_</span>
</p>
</div>
</div>
`
: nothing}
<div
class="casita-container ${this._dragging ? "dragging" : ""}"
style="width: ${size}px; ${posStyle}"
aria-hidden="true"
@pointerdown=${this._onPointerDown}
>
${this._showBubble
? html`
<div class="speech-bubble">
<span class="bubble-text">${this._bubbleText}</span>
<button
class="bubble-close"
@pointerdown=${this._stopPropagation}
@click=${this._closeBubble}
>
</button>
<button
class="bubble-dismiss"
@pointerdown=${this._stopPropagation}
@click=${this._dismiss}
>
${this.hass!.localize("ui.panel.retro.dismiss")}
</button>
<div class="bubble-arrow"></div>
</div>
`
: nothing}
<img
class="casita-image"
src="/static/images/voice-assistant/${this._expression}.png"
alt="Casita"
draggable="false"
/>
</div>
`;
}
static readonly styles = css`
:host {
display: block;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
user-select: none;
z-index: 9999;
}
.casita-container {
position: fixed;
pointer-events: auto;
cursor: grab;
user-select: none;
touch-action: none;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3));
}
.casita-container.dragging {
cursor: grabbing;
}
.casita-image {
width: 100%;
height: auto;
animation: bob 3s ease-in-out infinite;
pointer-events: none;
}
.dragging .casita-image {
animation: none;
}
.speech-bubble {
position: absolute;
bottom: calc(100% + 8px);
right: 0;
background: #ffffe1;
color: #000000;
border-radius: 12px;
border: 2px solid #000000;
padding: 12px 28px 12px 12px;
font-family: Tahoma, "MS Sans Serif", Arial, sans-serif;
font-size: 14px;
line-height: 1.4;
width: 300px;
box-sizing: border-box;
word-wrap: break-word;
overflow-wrap: break-word;
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
animation: bubble-in 200ms ease-out;
pointer-events: auto;
}
.bubble-close {
position: absolute;
top: 4px;
right: 4px;
background: none;
border: none;
cursor: pointer;
color: #000000;
font-size: 14px;
padding: 2px 6px;
line-height: 1;
border-radius: 50%;
}
.bubble-close:hover {
background: #e0e0c0;
}
.bubble-dismiss {
display: block;
margin-top: 8px;
background: none;
border: none;
cursor: pointer;
color: #808080;
font-family: Tahoma, "MS Sans Serif", Arial, sans-serif;
font-size: 12px;
padding: 0;
text-decoration: underline;
}
.bubble-dismiss:hover {
color: #000000;
}
.bubble-arrow {
position: absolute;
bottom: -8px;
right: 32px;
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 8px solid #ffffe1;
}
@keyframes bob {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
@keyframes bubble-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.bsod {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #0000aa;
color: #ffffff;
font-family: "Lucida Console", "Courier New", monospace;
font-size: 16px;
line-height: 1.6;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
pointer-events: auto;
animation: bsod-in 100ms ease-out;
}
.bsod-content {
max-width: 700px;
padding: 32px;
text-align: left;
}
.bsod-title {
display: inline-block;
background: #aaaaaa;
color: #0000aa;
padding: 2px 12px;
font-size: 18px;
font-weight: normal;
margin: 0 0 24px;
}
.bsod-content p {
margin: 16px 0;
}
.bsod-prompt {
margin-top: 32px;
}
.bsod-cursor {
animation: blink 1s step-end infinite;
}
@keyframes bsod-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes blink {
50% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.casita-image {
animation: none;
}
.speech-bubble {
animation: none;
}
.bsod {
animation: none;
}
.bsod-cursor {
animation: none;
}
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-retro": HaRetro;
}
}

View File

@@ -287,9 +287,7 @@ export class HaNumericThresholdSelector extends LitElement {
const numberSelector = {
number: {
...this.selector.numeric_threshold?.number,
...(!showUnit && effectiveUnit
? { unit_of_measurement: effectiveUnit }
: {}),
...(effectiveUnit ? { unit_of_measurement: effectiveUnit } : {}),
},
};
const entitySelector = {

View File

@@ -21,8 +21,6 @@ export class HaTimeInput extends LitElement {
@property({ type: Boolean }) public required = false;
@property({ attribute: "auto-validate", type: Boolean }) autoValidate = false;
@property({ type: Boolean, attribute: "enable-second" })
public enableSecond = false;
@@ -73,7 +71,6 @@ export class HaTimeInput extends LitElement {
.clearable=${this.clearable && this.value !== undefined}
.helper=${this.helper}
.placeholderLabels=${this.placeholderLabels}
.autoValidate=${this.autoValidate}
day-label="dd"
hour-label="hh"
min-label="mm"
@@ -89,7 +86,6 @@ export class HaTimeInput extends LitElement {
const useAMPM = useAmPm(this.locale);
let value: string | undefined;
let updateHours = 0;
// An undefined eventValue means the time selector is being cleared,
// the `value` variable will (intentionally) be left undefined.
@@ -101,8 +97,6 @@ export class HaTimeInput extends LitElement {
) {
let hours = eventValue.hours || 0;
if (eventValue && useAMPM) {
updateHours =
hours >= 12 && hours < 24 ? hours - 12 : hours === 0 ? 12 : 0;
if (eventValue.amPm === "PM" && hours < 12) {
hours += 12;
}
@@ -121,17 +115,6 @@ export class HaTimeInput extends LitElement {
}`;
}
if (updateHours) {
// If the user entered a 24hr time in a 12hr input, we need to refresh the
// input to ensure it resets back to the 12hr equivalent.
this.updateComplete.then(() => {
const input = this._input;
if (input) {
input.hours = updateHours;
}
});
}
if (value === this.value) {
return;
}

View File

@@ -1,12 +1,6 @@
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import {
customElement,
property,
query,
queryAssignedElements,
state,
} from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import { popoverSupported } from "../common/feature-detect/support-popover";
@@ -31,12 +25,6 @@ export class HaToast extends LitElement {
@query(".toast")
private _toast?: HTMLDivElement;
@queryAssignedElements({ slot: "action", flatten: true })
private _actionElements?: Element[];
@queryAssignedElements({ slot: "dismiss", flatten: true })
private _dismissElements?: Element[];
@state() private _active = false;
@state() private _visible = false;
@@ -175,10 +163,6 @@ export class HaToast extends LitElement {
}
protected render() {
const hasAction =
(this._actionElements?.length ?? 0) > 0 ||
(this._dismissElements?.length ?? 0) > 0;
return html`
<div
class=${classMap({
@@ -191,7 +175,7 @@ export class HaToast extends LitElement {
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
>
<span class="message">${this.labelText}</span>
<div class=${classMap({ actions: true, "has-action": hasAction })}>
<div class="actions">
<slot name="action"></slot>
<slot name="dismiss"></slot>
</div>
@@ -214,13 +198,21 @@ export class HaToast extends LitElement {
border: none;
overflow: hidden;
box-sizing: border-box;
min-width: min(350px, calc(var(--safe-width) - var(--ha-space-4)));
max-width: min(650px, var(--safe-width));
min-width: min(
350px,
calc(
100vw - var(--ha-space-4) - var(--safe-area-inset-left, 0px) - var(
--safe-area-inset-right,
0px
)
)
);
max-width: 650px;
min-height: 48px;
display: flex;
align-items: center;
gap: var(--ha-space-2);
padding: var(--ha-space-3) var(--ha-space-4);
padding: var(--ha-space-3);
color: var(--ha-color-on-neutral-loud);
background-color: var(--ha-color-neutral-10);
border-radius: var(--ha-border-radius-sm);
@@ -253,14 +245,14 @@ export class HaToast extends LitElement {
color: var(--ha-color-on-neutral-loud);
}
.actions:not(.has-action) {
display: none;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
.toast {
min-width: var(--safe-width);
max-width: var(--safe-width);
min-width: calc(
100vw - var(--safe-area-inset-left, 0px) - var(
--safe-area-inset-right,
0px
)
);
border-radius: var(--ha-border-radius-square);
}
}

View File

@@ -1,7 +1,13 @@
import type { Connection } from "home-assistant-js-websocket";
import { createCollection } from "home-assistant-js-websocket";
export type ThemeVars = Record<string, string>;
export interface ThemeVars {
// Incomplete
"primary-color": string;
"text-primary-color": string;
"accent-color": string;
[key: string]: string;
}
export type Theme = ThemeVars & {
modes?: {

View File

@@ -82,7 +82,6 @@ class MoreInfoInputDatetime extends LitElement {
display: flex;
align-items: center;
justify-content: flex-end;
--ha-input-padding-bottom: 0;
}
ha-date-input + ha-time-input {
margin-left: var(--ha-space-1);

View File

@@ -52,7 +52,6 @@ export class HomeAssistantMain extends LitElement {
return html`
<ha-snowflakes .hass=${this.hass} .narrow=${this.narrow}></ha-snowflakes>
<ha-retro .hass=${this.hass} .narrow=${this.narrow}></ha-retro>
<ha-drawer
.type=${sidebarNarrow ? "modal" : ""}
.open=${sidebarNarrow ? this._drawerOpen : false}
@@ -80,7 +79,6 @@ export class HomeAssistantMain extends LitElement {
protected firstUpdated() {
import(/* webpackPreload: true */ "../components/ha-sidebar");
import("../components/ha-snowflakes");
import("../components/ha-retro");
if (this.hass.auth.external) {
this._externalSidebar =

View File

@@ -486,17 +486,6 @@ class SupervisorAppInfo extends LitElement {
<div class="description light-color">
${this.addon.description}.<br />
${this.hass.localize(
"ui.panel.config.apps.dashboard.visit_app_page",
{
name: html`<a
href=${this.addon.url!}
target="_blank"
rel="noreferrer"
>${getAppDisplayName(this.addon.name, this.addon.stage)}</a
>`,
}
)}
</div>
<div class="addon-container">
<div>
@@ -1038,7 +1027,6 @@ class SupervisorAppInfo extends LitElement {
}
private _updateComplete() {
this._scheduleDataUpdate();
const eventdata = {
success: true,
response: undefined,
@@ -1060,16 +1048,11 @@ class SupervisorAppInfo extends LitElement {
};
fireEvent(this, "hass-api-called", eventdata);
} catch (err: any) {
showConfirmationDialog(this, {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.apps.dashboard.action_error.install"
),
text: extractApiErrorMessage(err),
confirmText: this.hass.localize("ui.common.ok"),
dismissText: this.hass.localize(
"ui.panel.config.apps.dashboard.action_error.view_supervisor_logs"
),
cancel: () => navigate("/config/logs?provider=supervisor"),
});
}
button.progress = false;

View File

@@ -198,7 +198,7 @@ export default class HaAutomationAction extends AutomationSortableListMixin<Acti
private _addAction = (action: string, target?: HassServiceTarget) => {
let actions: Action[];
if (action === PASTE_VALUE) {
actions = this.actions.concat(deepClone(this._clipboard!.action!));
actions = this.actions.concat(deepClone(this._clipboard!.action));
} else if (action in VIRTUAL_ACTIONS) {
actions = this.actions.concat(VIRTUAL_ACTIONS[action]);
} else if (isDynamic(action)) {

View File

@@ -287,7 +287,7 @@ export default class HaAutomationCondition extends AutomationSortableListMixin<C
let conditions: Condition[];
if (value === PASTE_VALUE) {
conditions = this.conditions.concat(
deepClone(this._clipboard!.condition!)
deepClone(this._clipboard!.condition)
);
} else if (isDynamic(value)) {
conditions = this.conditions.concat({

View File

@@ -25,6 +25,7 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../../common/color/compute-color";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
@@ -1321,6 +1322,7 @@ ${rejected
private _renderLabelItems = (slot = "") =>
html`${this._labels?.map((label) => {
const color = label.color ? computeCssColor(label.color) : undefined;
const selected = this._selected.every((entityId) =>
this.hass.entities[entityId]?.labels.includes(label.label_id)
);
@@ -1340,7 +1342,10 @@ ${rejected
.indeterminate=${partial}
reducedTouchTarget
></ha-checkbox>
<ha-label .color=${label.color} .description=${label.description}>
<ha-label
style=${color ? `--color: ${color}` : ""}
.description=${label.description}
>
${label.icon
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
: nothing}
@@ -1485,6 +1490,10 @@ ${rejected
ha-dropdown ha-assist-chip {
--md-assist-chip-trailing-space: 8px;
}
ha-label {
--ha-label-background-color: var(--color, var(--grey-color));
--ha-label-background-opacity: 0.5;
}
`,
];
}

View File

@@ -203,7 +203,7 @@ export default class HaAutomationTrigger extends AutomationSortableListMixin<Tri
private _addTrigger = (value: string, target?: HassServiceTarget) => {
let triggers: Trigger[];
if (value === PASTE_VALUE) {
triggers = this.triggers.concat(deepClone(this._clipboard!.trigger!));
triggers = this.triggers.concat(deepClone(this._clipboard!.trigger));
} else if (isDynamic(value)) {
triggers = this.triggers.concat({
trigger: getValueFromDynamic(value),

View File

@@ -3,6 +3,7 @@ import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../../../common/color/compute-color";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import { computeDeviceNameDisplay } from "../../../../common/entity/compute_device_name";
import { stringCompare } from "../../../../common/string/compare";
@@ -171,9 +172,13 @@ export class HaDeviceCard extends LitElement {
<div class="extra-info labels">
${labels.map((labelId) => {
const label = labelMap.get(labelId);
const color =
label?.color && typeof label.color === "string"
? computeCssColor(label.color)
: undefined;
return html`
<ha-label
.color=${label?.color}
style=${color ? `--color: ${color}` : ""}
.description=${label?.description}
>
${label?.icon
@@ -238,6 +243,12 @@ export class HaDeviceCard extends LitElement {
max-width: 100%;
flex: 0 1 auto;
}
ha-label {
--ha-label-background-color: var(--color, var(--grey-color));
--ha-label-background-opacity: 0.5;
--ha-label-text-color: var(--primary-text-color);
--ha-label-icon-color: var(--primary-text-color);
}
.extra-info {
margin-top: var(--ha-space-2);
word-wrap: break-word;

View File

@@ -10,9 +10,11 @@ import {
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../../common/color/compute-color";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
@@ -34,6 +36,7 @@ import type {
SelectionChangedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/data-table/ha-data-table-labels";
import "../../../components/entity/ha-battery-icon";
import "../../../components/ha-alert";
@@ -678,6 +681,7 @@ export class HaConfigDeviceDashboard extends LitElement {
private _renderLabelItems = (slot = "") =>
html`${this._labels?.map((label) => {
const color = label.color ? computeCssColor(label.color) : undefined;
const selected = this._selected.every((deviceId) =>
this.hass.devices[deviceId]?.labels.includes(label.label_id)
);
@@ -699,7 +703,7 @@ export class HaConfigDeviceDashboard extends LitElement {
reducedTouchTarget
></ha-checkbox>
<ha-label
.color=${label.color}
style=${color ? `--color: ${color}` : ""}
.description=${label.description || undefined}
>
${label.icon
@@ -1250,6 +1254,10 @@ ${rejected
ha-dropdown ha-assist-chip {
--md-assist-chip-trailing-space: 8px;
}
ha-label {
--ha-label-background-color: var(--color, var(--grey-color));
--ha-label-background-opacity: 0.5;
}
`,
haStyle,
];

View File

@@ -23,6 +23,7 @@ import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import memoize from "memoize-one";
import { computeCssColor } from "../../../common/color/compute-color";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { computeAreaName } from "../../../common/entity/compute_area_name";
@@ -753,6 +754,7 @@ export class HaConfigEntities extends LitElement {
private _renderLabelItems = (slot = "") =>
html`${this._labels?.map((label) => {
const color = label.color ? computeCssColor(label.color) : undefined;
const selected = this._selected.every((entityId) =>
this.hass.entities[entityId]?.labels.includes(label.label_id)
);
@@ -772,7 +774,10 @@ export class HaConfigEntities extends LitElement {
.indeterminate=${partial}
reducedTouchTarget
></ha-checkbox>
<ha-label .color=${label.color} .description=${label.description}>
<ha-label
style=${color ? `--color: ${color}` : ""}
.description=${label.description}
>
${label.icon
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
: nothing}
@@ -1673,6 +1678,10 @@ ${rejected
ha-dropdown ha-assist-chip {
--md-assist-chip-trailing-space: 8px;
}
ha-label {
--ha-label-background-color: var(--color, var(--grey-color));
--ha-label-background-opacity: 0.5;
}
`,
];
}

View File

@@ -19,6 +19,7 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../../common/color/compute-color";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
@@ -1428,6 +1429,7 @@ ${rejected
private _renderLabelItems = (slot = "") =>
html`${this._labels?.map((label) => {
const color = label.color ? computeCssColor(label.color) : undefined;
const selected = this._selected.every((entityId) =>
this._labelsForEntity(entityId).includes(label.label_id)
);
@@ -1447,7 +1449,10 @@ ${rejected
.indeterminate=${partial}
reducedTouchTarget
></ha-checkbox>
<ha-label .color=${label.color} .description=${label.description}>
<ha-label
style=${color ? `--color: ${color}` : ""}
.description=${label.description}
>
${label.icon
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
: nothing}
@@ -1533,6 +1538,10 @@ ${rejected
ha-dropdown ha-assist-chip {
--md-assist-chip-trailing-space: 8px;
}
ha-label {
--ha-label-background-color: var(--color, var(--grey-color));
--ha-label-background-opacity: 0.5;
}
`,
];
}

View File

@@ -46,14 +46,11 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
const featuresToSort = [...features];
return featuresToSort.sort((a, b) => {
// Place frontend fun features at the bottom
const funFeatures = ["winter_mode", "retro"];
const aIsFun =
a.domain === "frontend" && funFeatures.includes(a.preview_feature);
const bIsFun =
b.domain === "frontend" && funFeatures.includes(b.preview_feature);
if (aIsFun && !bIsFun) return 1;
if (bIsFun && !aIsFun) return -1;
// Place frontend.winter_mode at the bottom
if (a.domain === "frontend" && a.preview_feature === "winter_mode")
return 1;
if (b.domain === "frontend" && b.preview_feature === "winter_mode")
return -1;
// Sort everything else alphabetically
return domainToName(localize, a.domain).localeCompare(

View File

@@ -22,6 +22,7 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../../common/color/compute-color";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
@@ -1115,6 +1116,7 @@ ${rejected
private _renderLabelItems = (slot = "") =>
html`${this._labels?.map((label) => {
const color = label.color ? computeCssColor(label.color) : undefined;
const selected = this._selected.every((entityId) =>
this.hass.entities[entityId]?.labels.includes(label.label_id)
);
@@ -1134,7 +1136,10 @@ ${rejected
.indeterminate=${partial}
reducedTouchTarget
></ha-checkbox>
<ha-label .color=${label.color} .description=${label.description}>
<ha-label
style=${color ? `--color: ${color}` : ""}
.description=${label.description}
>
${label.icon
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
: nothing}
@@ -1267,6 +1272,10 @@ ${rejected
ha-dropdown ha-assist-chip {
--md-assist-chip-trailing-space: 8px;
}
ha-label {
--ha-label-background-color: var(--color, var(--grey-color));
--ha-label-background-opacity: 0.5;
}
`,
];
}

View File

@@ -23,6 +23,7 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../../common/color/compute-color";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
@@ -1174,6 +1175,7 @@ ${rejected
private _renderLabelItems = (slot = "") =>
html`${this._labels?.map((label) => {
const color = label.color ? computeCssColor(label.color) : undefined;
const selected = this._selected.every((entityId) =>
this.hass.entities[entityId]?.labels.includes(label.label_id)
);
@@ -1193,7 +1195,10 @@ ${rejected
.indeterminate=${partial}
reducedTouchTarget
></ha-checkbox>
<ha-label .color=${label.color} .description=${label.description}>
<ha-label
style=${color ? `--color: ${color}` : ""}
.description=${label.description}
>
${label.icon
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
: nothing}
@@ -1325,6 +1330,10 @@ ${rejected
ha-dropdown ha-assist-chip {
--md-assist-chip-trailing-space: 8px;
}
ha-label {
--ha-label-background-color: var(--color, var(--grey-color));
--ha-label-background-opacity: 0.5;
}
`,
];
}

View File

@@ -35,7 +35,6 @@ import { formatTime } from "../../../../../common/datetime/format_time";
import type { ECOption } from "../../../../../resources/echarts/echarts";
import { filterXSS } from "../../../../../common/util/xss";
import type { StatisticPeriod } from "../../../../../data/recorder";
import { getPeriodicAxisLabelConfig } from "../../../../../components/chart/axis-label";
import { getSuggestedPeriod } from "../../../../../data/energy";
// Number of days of padding when showing time axis in months
@@ -110,7 +109,17 @@ export function getCommonOptions(
type: "time",
min: subDays(start, MONTH_TIME_AXIS_PADDING),
max: addDays(suggestedMax, MONTH_TIME_AXIS_PADDING),
axisLabel: getPeriodicAxisLabelConfig("month", locale, config),
axisLabel: {
formatter: {
year: "{yearStyle|{MMMM} {yyyy}}",
month: "{MMMM}",
},
rich: {
yearStyle: {
fontWeight: "bold",
},
},
},
// For shorter month ranges, force splitting to ensure time axis renders
// as whole month intervals. Limit the number of forced ticks to 6 months
// (so a max calendar difference of 5) to reduce clutter.

View File

@@ -43,7 +43,6 @@ const COLORS: Record<HomeSummary, string> = {
security: "blue-grey",
media_players: "blue",
energy: "amber",
persons: "green",
};
@customElement("hui-home-summary-card")
@@ -258,21 +257,6 @@ export class HuiHomeSummaryCard
const totalConsumption = consumption.total.used_total;
return formatConsumptionShort(this.hass, totalConsumption, "kWh");
}
case "persons": {
const personsFilters = HOME_SUMMARIES_FILTERS.persons.map((filter) =>
generateEntityFilter(this.hass!, filter)
);
const personEntities = findEntities(allEntities, personsFilters);
const personsHome = personEntities.filter((entityId) => {
const s = this.hass!.states[entityId]?.state;
return s === "home";
});
return personsHome.length
? this.hass.localize("ui.card.home-summary.count_persons_home", {
count: personsHome.length,
})
: this.hass.localize("ui.card.home-summary.nobody_home");
}
}
return "";
}

View File

@@ -10,7 +10,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { getColorByIndex } from "../../../common/color/colors";
import { resolveThemeColor } from "../../../common/color/compute-color";
import { computeCssVariableName } from "../../../common/color/compute-color";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
@@ -431,6 +431,15 @@ class HuiMapCard extends LitElement implements LovelaceCard {
return color;
}
private _resolveColor(color: string): string {
const cssColor = computeCssVariableName(color);
if (cssColor.startsWith("--")) {
const resolved = getComputedStyle(this).getPropertyValue(cssColor).trim();
return resolved || color;
}
return cssColor;
}
private _getSourceEntities(states?: HassEntities): GeoEntity[] {
if (!states || !this._config?.geo_location_sources) {
return [];
@@ -470,7 +479,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
...(this._configEntities || []).map((entityConf) => ({
entity_id: entityConf.entity,
color: entityConf.color
? resolveThemeColor(entityConf.color)
? this._resolveColor(entityConf.color)
: this._getColor(entityConf.entity),
label_mode: entityConf.label_mode,
attribute: entityConf.attribute,
@@ -532,7 +541,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
name,
fullDatetime: (config.hours_to_show ?? DEFAULT_HOURS_TO_SHOW) > 144,
color: entityConfig?.color
? resolveThemeColor(entityConfig.color)
? this._resolveColor(entityConfig.color)
: this._getColor(entityId),
gradualOpacity: 0.8,
});

View File

@@ -19,10 +19,8 @@ import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-svg-icon";
import {
ensureBadgeConfig,
type LovelaceBadgeConfig,
} from "../../../data/lovelace/config/badge";
import { ensureBadgeConfig } from "../../../data/lovelace/config/badge";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { showEditBadgeDialog } from "../editor/badge-editor/show-edit-badge-dialog";
@@ -60,7 +58,7 @@ export class HuiBadgeEditMode extends LitElement {
subscribe: false,
storage: "sessionStorage",
})
protected _clipboard?: string | Partial<LovelaceBadgeConfig>;
protected _clipboard?: LovelaceCardConfig;
private get _badges() {
const containerPath = getLovelaceContainerPath(this.path!);

View File

@@ -1,23 +1,13 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import {
mdiDelete,
mdiDotsVertical,
mdiDragHorizontalVariant,
mdiPencil,
mdiPlusCircleMultipleOutline,
} from "@mdi/js";
import { mdiDelete, mdiDragHorizontalVariant, mdiPencil } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-svg-icon";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { deleteSection, duplicateSection } from "../editor/config-util";
import { deleteSection } from "../editor/config-util";
import { findLovelaceContainer } from "../editor/lovelace-path";
import { showEditSectionDialog } from "../editor/section-editor/show-edit-section-dialog";
import type { Lovelace } from "../types";
@@ -41,32 +31,16 @@ export class HuiSectionEditMode extends LitElement {
class="handle"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
<ha-dropdown
placement="bottom-end"
@wa-select=${this._handleDropdownSelect}
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item value="edit">
<ha-svg-icon slot="icon" .path=${mdiPencil}></ha-svg-icon>
${this.hass.localize("ui.common.edit")}
</ha-dropdown-item>
<ha-dropdown-item value="duplicate">
<ha-svg-icon
slot="icon"
.path=${mdiPlusCircleMultipleOutline}
></ha-svg-icon>
${this.hass.localize("ui.common.duplicate")}
</ha-dropdown-item>
<wa-divider></wa-divider>
<ha-dropdown-item value="delete" variant="danger">
<ha-svg-icon slot="icon" .path=${mdiDelete}></ha-svg-icon>
${this.hass.localize("ui.common.delete")}
</ha-dropdown-item>
</ha-dropdown>
<ha-icon-button
.label=${this.hass.localize("ui.common.edit")}
@click=${this._editSection}
.path=${mdiPencil}
></ha-icon-button>
<ha-icon-button
.label=${this.hass.localize("ui.common.delete")}
@click=${this._deleteSection}
.path=${mdiDelete}
></ha-icon-button>
</div>
</div>
<div class="section-wrapper">
@@ -75,23 +49,8 @@ export class HuiSectionEditMode extends LitElement {
`;
}
private _handleDropdownSelect(ev: HaDropdownSelectEvent): void {
const action = ev.detail?.item?.value;
if (!action) return;
switch (action) {
case "edit":
this._editSection();
break;
case "duplicate":
this._duplicateSection();
break;
case "delete":
this._deleteSection();
break;
}
}
private async _editSection() {
private async _editSection(ev) {
ev.stopPropagation();
showEditSectionDialog(this, {
lovelace: this.lovelace!,
lovelaceConfig: this.lovelace!.config,
@@ -103,16 +62,8 @@ export class HuiSectionEditMode extends LitElement {
});
}
private _duplicateSection(): void {
const newConfig = duplicateSection(
this.lovelace!.config,
this.viewIndex,
this.index
);
this.lovelace!.saveConfig(newConfig);
}
private async _deleteSection() {
private async _deleteSection(ev) {
ev.stopPropagation();
const path = [this.viewIndex, this.index] as [number, number];
const section = findLovelaceContainer(this.lovelace!.config, path);

View File

@@ -153,7 +153,7 @@ export class HaCardConditionsEditor extends LitElement {
}
if (condition === "paste") {
const newCondition = deepClone(this._clipboard!);
const newCondition = deepClone(this._clipboard);
conditions.push(newCondition);
} else {
const elClass = customElements.get(`ha-card-condition-${condition}`) as

View File

@@ -1,4 +1,3 @@
import deepClone from "deep-clone-simple";
import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
import { ensureBadgeConfig } from "../../../data/lovelace/config/badge";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
@@ -304,19 +303,6 @@ export const deleteSection = (
return newConfig;
};
export const duplicateSection = (
config: LovelaceConfig,
viewIndex: number,
sectionIndex: number
): LovelaceConfig => {
const view = findLovelaceContainer(config, [viewIndex]);
if (isStrategyView(view)) {
throw new Error("Duplicating sections in a strategy is not supported.");
}
const clone = deepClone(view.sections![sectionIndex]);
return insertSection(config, viewIndex, sectionIndex + 1, clone);
};
export const insertSection = (
config: LovelaceConfig,
viewIndex: number,

View File

@@ -112,7 +112,7 @@ export class HuiButtonHeadingBadge
width: auto;
height: var(--ha-heading-badge-size, 26px);
min-width: var(--ha-heading-badge-size, 26px);
font-size: var(--ha-heading-badge-font-size, var(--ha-font-size-s));
font-size: var(--ha-heading-badge-font-size, var(--ha-font-size-m));
font-weight: var(--ha-font-weight-medium);
}
ha-control-button.with-text {
@@ -135,9 +135,6 @@ export class HuiButtonHeadingBadge
padding: 0 var(--ha-space-1);
line-height: 1;
}
ha-icon {
line-height: 1;
}
`;
}

View File

@@ -110,19 +110,11 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
public disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener(
"card-visibility-changed",
this._cardVisibilityChanged
);
}
public connectedCallback() {
super.connectedCallback();
this._updateVisibility();
this.addEventListener(
"card-visibility-changed",
this._cardVisibilityChanged
);
}
protected update(changedProperties) {
@@ -152,11 +144,7 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
if (changedProperties.has("_cards")) {
this._layoutElement.cards = this._cards;
}
if (
changedProperties.has("hass") ||
changedProperties.has("preview") ||
changedProperties.has("_cards")
) {
if (changedProperties.has("hass") || changedProperties.has("preview")) {
this._updateVisibility();
}
}
@@ -212,10 +200,6 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
}
}
private _cardVisibilityChanged = () => {
this._updateVisibility();
};
protected _updateVisibility(conditionsMet?: boolean) {
if (!this._layoutElement || !this._config) {
return;
@@ -236,16 +220,7 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
(!this._config.visibility ||
checkConditionsMet(this._config.visibility, this.hass));
if (!visible) {
this._setElementVisibility(false);
return;
}
// Hide section when all cards are conditionally hidden
const allCardsHidden =
this._cards.length > 0 && this._cards.every((card) => card.hidden);
this._setElementVisibility(!allCardsHidden);
this._setElementVisibility(visible);
}
private _setElementVisibility(visible: boolean) {
@@ -257,9 +232,9 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
fireEvent(this, "section-visibility-changed", { value: visible });
}
// Always keep layout element connected so cards can still update
// their visibility and bubble events back to the section.
if (!this._layoutElement.parentElement) {
if (!visible && this._layoutElement.parentElement) {
this.removeChild(this._layoutElement);
} else if (visible && !this._layoutElement.parentElement) {
this.appendChild(this._layoutElement);
}
}

View File

@@ -10,7 +10,6 @@ export const HOME_SUMMARIES = [
"security",
"media_players",
"energy",
"persons",
] as const;
export type HomeSummary = (typeof HOME_SUMMARIES)[number];
@@ -21,7 +20,6 @@ export const HOME_SUMMARIES_ICONS: Record<HomeSummary, string> = {
security: "mdi:security",
media_players: "mdi:multimedia",
energy: "mdi:lightning-bolt",
persons: "mdi:account-multiple",
};
export const HOME_SUMMARIES_FILTERS: Record<HomeSummary, EntityFilter[]> = {
@@ -30,7 +28,6 @@ export const HOME_SUMMARIES_FILTERS: Record<HomeSummary, EntityFilter[]> = {
security: securityEntityFilters,
media_players: [{ domain: "media_player", entity_category: "none" }],
energy: [], // Uses energy collection data
persons: [{ domain: "person" }],
};
export const getSummaryLabel = (

View File

@@ -216,9 +216,7 @@
"count_alarms_disarmed": "{count} {count, plural,\n one {disarmed}\n other {disarmed}\n}",
"all_secure": "All secure",
"no_media_playing": "No media playing",
"count_media_playing": "{count} {count, plural,\n one {playing}\n other {playing}\n}",
"count_persons_home": "{count} {count, plural,\n one {person}\n other {people}\n}",
"nobody_home": "No one home"
"count_media_playing": "{count} {count, plural,\n one {playing}\n other {playing}\n}"
},
"toggle-group": {
"all_off": "All off",
@@ -2741,7 +2739,6 @@
"current_version": "Current version: {version}",
"changelog": "Changelog",
"hostname": "Hostname",
"visit_app_page": "Visit {name} page for more details.",
"start": "Start",
"stop": "Stop",
"restart": "Restart",
@@ -2868,8 +2865,7 @@
"get_changelog": "Failed to get changelog",
"start_invalid_config": "Invalid configuration",
"go_to_config": "Go to configuration",
"validate_config": "Failed to validate app configuration",
"view_supervisor_logs": "View supervisor logs"
"validate_config": "Failed to validate app configuration"
},
"uninstall_dialog": {
"title": "Uninstall {name}?",
@@ -5008,7 +5004,7 @@
"before": "Before",
"after": "After",
"description": {
"picker": "Triggers when a calendar event starts or ends.",
"picker": "When a calendar event starts or ends.",
"full": "When{offsetChoice, select, \n before { it's {offset} before}\n after { it's {offset} after}\n other {}\n} a calendar event{eventChoice, select, \n start { starts}\n end { ends}\n other { starts or ends}\n}{hasCalendar, select, \n true { in {calendar}}\n other {}\n}"
}
},
@@ -5016,7 +5012,7 @@
"label": "Device",
"trigger": "Trigger",
"description": {
"picker": "Triggers when something happens to a device. Great way to start."
"picker": "When something happens to a device. Great way to start."
}
},
"event": {
@@ -5027,7 +5023,7 @@
"context_user_picked": "User firing event",
"context_user_pick": "Select user",
"description": {
"picker": "Triggers when an event is being received (event is an advanced concept in Home Assistant).",
"picker": "When an event is being received (event is an advanced concept in Home Assistant).",
"full": "When {eventTypes} event is fired"
}
},
@@ -5039,7 +5035,7 @@
"enter": "Enter",
"leave": "Leave",
"description": {
"picker": "Triggers when an entity created by a geolocation platform appears in or disappears from a zone.",
"picker": "When an entity created by a geolocation platform appears in or disappears from a zone.",
"full": "When {source} {event, select, \n enter {enters}\n leave {leaves} other {} \n} {zone} {numberOfZones, plural,\n one {zone}\n other {zones}\n}"
}
},
@@ -5051,7 +5047,7 @@
"to": "To (optional)",
"any_state_ignore_attributes": "Any state (ignoring attribute changes)",
"description": {
"picker": "Triggers when the state of an entity (or attribute) changes.",
"picker": "When the state of an entity (or attribute) changes.",
"full": "When{hasAttribute, select, \n true { {attribute} of} \n other {}\n} {hasEntity, select, \n true {{entity}} \n other {something}\n} changes{fromChoice, select, \n fromUsed { from {fromString}}\n null { from any state} \n other {}\n}{toChoice, select, \n toUsed { to {toString}} \n null { to any state} \n special { state or any attributes} \n other {}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n}"
}
},
@@ -5061,7 +5057,7 @@
"start": "Start",
"shutdown": "Shutdown",
"description": {
"picker": "Triggers when Home Assistant starts up or shuts down.",
"picker": "When Home Assistant starts up or shuts down.",
"started": "When Home Assistant is started",
"shutdown": "When Home Assistant is shut down"
}
@@ -5071,7 +5067,7 @@
"topic": "Topic",
"payload": "Payload (optional)",
"description": {
"picker": "Triggers when a specific message is received on a given MQTT topic.",
"picker": "When a specific message is received on a given MQTT topic.",
"full": "When an MQTT message has been received"
}
},
@@ -5085,7 +5081,7 @@
"type_value": "Fixed number",
"type_input": "Numeric value of another entity",
"description": {
"picker": "Triggers when the numeric value of an entity''s state (or attribute''s value) crosses a given threshold.",
"picker": "When the numeric value of an entity''s state (or attribute''s value) crosses a given threshold.",
"above": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} above {above}{duration, select, \n undefined {} \n other { for {duration}}\n }",
"below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }",
"above-below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} above {above} and below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }"
@@ -5102,7 +5098,7 @@
"updated": "updated"
},
"description": {
"picker": "Triggers when a persistent notification is added or removed.",
"picker": "When a persistent notification is added or removed.",
"full": "When a persistent notification is updated"
}
},
@@ -5113,7 +5109,7 @@
"sunset": "Sunset",
"offset": "Offset in seconds or HH:MM:SS (optional)",
"description": {
"picker": "Triggers when the sun sets or rises.",
"picker": "When the sun sets or rises.",
"sets": "When the sun sets{hasDuration, select, \n true { offset by {duration}} \n other {}\n }",
"rises": "When the sun rises{hasDuration, select, \n true { offset by {duration}} \n other {}\n }"
}
@@ -5125,7 +5121,7 @@
"delete": "Delete sentence",
"confirm_delete": "Are you sure you want to delete this sentence?",
"description": {
"picker": "Triggers when Assist matches a sentence from a voice assistant.",
"picker": "When Assist matches a sentence from a voice assistant.",
"empty": "When a sentence is said",
"single": "When the sentence ''{sentence}'' is said",
"multiple": "When the sentence ''{sentence}'' or {count, plural,\n one {another}\n other {{count} others}\n} are said"
@@ -5134,7 +5130,7 @@
"tag": {
"label": "Tag",
"description": {
"picker": "Triggers when a tag is scanned (tags are usually created from the Companion app).",
"picker": "When a tag is scanned (tags are usually created from the Companion app).",
"full": "When a tag is scanned",
"known_tag": "When scanning tag {tag_name}"
}
@@ -5144,7 +5140,7 @@
"value_template": "Value template",
"for": "For",
"description": {
"picker": "Triggers when a template evaluates to true.",
"picker": "When a template evaluates to true.",
"full": "When a template changes from false to true{hasDuration, select, \n true { for {duration}} \n other {}\n }"
}
},
@@ -5168,7 +5164,7 @@
"sun": "[%key:ui::weekdays::sunday%]"
},
"description": {
"picker": "Triggers at a specific time, or on a specific date.",
"picker": "At a specific time, or on a specific date.",
"full": "When the time is equal to {time}{hasWeekdays, select, \n true { on {weekdays}} \n other {}\n}"
}
},
@@ -5179,7 +5175,7 @@
"minutes": "Minutes",
"seconds": "Seconds",
"description": {
"picker": "Triggers periodically, at a defined interval.",
"picker": "Periodically, at a defined interval.",
"initial": "When a time pattern matches",
"invalid": "Invalid time pattern for {parts}",
"full": "Trigger {secondsChoice, select, \n every {every second of }\n every_interval {every {seconds} seconds of }\n on_the_xth {on the {secondsWithOrdinal} second of }\n other {}\n} {minutesChoice, select, \n every {every minute of }\n every_interval {every {minutes} minutes of }\n has_seconds {the {minutesWithOrdinal} minute of }\n on_the_xth {on the {minutesWithOrdinal} minute of }\n other {}\n} {hoursChoice, select, \n every {every hour}\n every_interval {every {hours} hours}\n has_seconds_or_minutes {the {hoursWithOrdinal} hour}\n on_the_xth {on the {hoursWithOrdinal} hour}\n other {}\n}",
@@ -5194,7 +5190,7 @@
"webhook_id_helper": "Treat this ID like a password: keep it secret and make it hard to guess.",
"webhook_settings": "Webhook settings",
"description": {
"picker": "Triggers when Home Assistant receives a web request to a webhook endpoint.",
"picker": "When Home Assistant receives a web request to the webhook endpoint.",
"full": "When a Webhook payload has been received"
}
},
@@ -5206,7 +5202,7 @@
"enter": "Enter",
"leave": "Leave",
"description": {
"picker": "Triggers when someone (or something) enters or leaves a zone.",
"picker": "When someone (or something) enters or leaves a zone.",
"full": "When {entity} {event, select, \n enter {enters}\n leave {leaves} other {} \n} {zone} {numberOfZones, plural,\n one {zone} \n other {zones}\n}"
}
},
@@ -8220,8 +8216,7 @@
"media_players": "Media players",
"other_devices": "Other devices",
"weather": "Weather",
"energy": "Today's energy",
"persons": "People at home"
"energy": "Today's energy"
},
"welcome_user": "Welcome {user}",
"summaries": "Summaries",
@@ -10705,39 +10700,6 @@
"add_card": "Add current view as card",
"add_card_error": "Unable to add card",
"error_no_data": "You need to select some data sources first."
},
"retro": {
"tip_1": "Try turning your house off and on again.",
"tip_2": "If your automation doesn't work, just add more YAML.",
"tip_3": "Talk to your devices. They won't answer, but it helps.",
"tip_4": "The best way to secure your smart home is to go back to candles.",
"tip_5": "Rebooting fixes everything. Everything.",
"tip_6": "Naming your vacuum 'DJ Roomba' increases cleaning efficiency by 200%.",
"tip_7": "Your automations run better when you're not looking.",
"tip_8": "Every time you restart Home Assistant, a smart bulb loses its pairing.",
"tip_9": "The cloud is just someone else's Raspberry Pi.",
"tip_10": "You can automate your coffee machine, but you still have to drink it yourself.",
"tip_11": "You can save energy by not having a home.",
"tip_12": "Psst... you can drag me anywhere you want!",
"tip_13": "Did you know? I never sleep. Well, sometimes I do. Zzz...",
"tip_14": "Zigbee, Z-Wave, Wi-Fi, Thread... so many protocols, so little time.",
"tip_15": "The sun can trigger your automations. Nature is the best sensor.",
"tip_16": "It looks like you're trying to automate your home! Would you like help?",
"tip_17": "My previous job was a paperclip. I got promoted.",
"tip_18": "I run entirely on YAML and good vibes.",
"tip_19": "Somewhere, a smart plug is blinking and nobody knows why.",
"tip_20": "Home Assistant runs on a Raspberry Pi. I run on hopes and dreams.",
"tip_21": "Behind every great home, there's someone staring at logs at 2am.",
"tip_22": "404: Motivation not found. Try again after coffee.",
"tip_23": "There are two types of people: those who back up, and those who will.",
"tip_24": "My favorite color is #008080. Don't ask me why.",
"tip_25": "Automations are just spicy if-then statements.",
"dismiss": "Dismiss me",
"bsod_title": "Home Assistant",
"bsod_error": "A fatal exception 0E has occurred at C0FF:EE15G00D in VXD L1GHT5(01) + 0FF. The current automation will be terminated.",
"bsod_line_1": "Don't worry, nothing is actually broken.",
"bsod_line_2": "Your automations are still running. Probably.",
"bsod_continue": "Press any key or click to continue"
}
},
"tips": {

View File

@@ -1,3 +0,0 @@
declare module "deep-clone-simple" {
export default function deepClone<T>(data: T): T;
}

View File

@@ -47,11 +47,8 @@ describe("Color Conversion Tests", () => {
});
it("should convert theme color to hex (ignoring alpha)", () => {
// Warning: theme2hex("red") returns a value of `--red-color` variable
// which can differ from `#ff0000` on a particular Frontend client
expect(theme2hex("red")).toBe("#ff0000");
expect(theme2hex("ReD")).toBe("#ff0000");
expect(theme2hex("#ff0000")).toBe("#ff0000");
expect(theme2hex("unicorn")).toBe("unicorn");
expect(theme2hex("#abc")).toBe("#aabbcc");

View File

@@ -1,10 +1,7 @@
import { assert, describe, it } from "vitest";
import type { LovelaceConfig } from "../../../../src/data/lovelace/config/types";
import type { LovelaceSectionConfig } from "../../../../src/data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../../src/data/lovelace/config/view";
import {
duplicateSection,
moveCardToContainer,
swapView,
} from "../../../../src/panels/lovelace/editor/config-util";
@@ -144,84 +141,3 @@ describe("swapView", () => {
assert.deepEqual(expected, result);
});
});
describe("duplicateSection", () => {
it("inserts a clone immediately after the original section", () => {
const config: LovelaceConfig = {
views: [
{
sections: [
{ type: "grid", cards: [{ type: "button" }] },
{ type: "grid", cards: [{ type: "heading" }] },
],
},
],
};
const result = duplicateSection(config, 0, 0);
const expected: LovelaceConfig = {
views: [
{
sections: [
{ type: "grid", cards: [{ type: "button" }] },
{ type: "grid", cards: [{ type: "button" }] },
{ type: "grid", cards: [{ type: "heading" }] },
],
},
],
};
assert.deepEqual(expected, result);
});
it("preserves all cards and properties within the cloned section", () => {
const config: LovelaceConfig = {
views: [
{
sections: [
{
type: "grid",
column_span: 2,
cards: [{ type: "button" }, { type: "heading" }],
},
],
},
],
};
const result = duplicateSection(config, 0, 0);
const view = result.views[0] as LovelaceViewConfig;
assert.equal(view.sections!.length, 2);
assert.deepEqual(view.sections![0], view.sections![1]);
});
it("produces a deep clone, changes do not affect the original", () => {
const config: LovelaceConfig = {
views: [
{
sections: [
{
type: "grid",
column_span: 2,
cards: [{ type: "button" }, { type: "heading" }],
},
],
},
],
};
const result = duplicateSection(config, 0, 0);
const resultSections = (result.views[0] as LovelaceViewConfig).sections!;
assert.equal(resultSections.length, 2);
assert.deepEqual(resultSections[0], resultSections[1]);
(resultSections[1] as LovelaceSectionConfig).cards![0].type = "heading";
assert.equal(
(resultSections[0] as LovelaceSectionConfig).cards![0].type,
"button"
);
});
});

146
yarn.lock
View File

@@ -4170,12 +4170,12 @@ __metadata:
languageName: node
linkType: hard
"@swc/helpers@npm:0.5.20":
version: 0.5.20
resolution: "@swc/helpers@npm:0.5.20"
"@swc/helpers@npm:0.5.19":
version: 0.5.19
resolution: "@swc/helpers@npm:0.5.19"
dependencies:
tslib: "npm:^2.8.0"
checksum: 10/a46030291484f8fd57505c4ae13cb179aa1f0cef201b14a065d857cfe3c3f41aab46d410a9cec7785f4768ac5b78dc4d07c344086c0ea2cacf67ba034fbed7a2
checksum: 10/3fd365fb3265f97e1241bcbcea9bfa5e15e03c630424e1b54597e00d30be2c271cb0c74f45e1739c6bc5ae892647302fab412de5138941aa96e66aebf4586700
languageName: node
linkType: hard
@@ -5114,12 +5114,12 @@ __metadata:
languageName: node
linkType: hard
"@vitest/coverage-v8@npm:4.1.2":
version: 4.1.2
resolution: "@vitest/coverage-v8@npm:4.1.2"
"@vitest/coverage-v8@npm:4.1.1":
version: 4.1.1
resolution: "@vitest/coverage-v8@npm:4.1.1"
dependencies:
"@bcoe/v8-coverage": "npm:^1.0.2"
"@vitest/utils": "npm:4.1.2"
"@vitest/utils": "npm:4.1.1"
ast-v8-to-istanbul: "npm:^1.0.0"
istanbul-lib-coverage: "npm:^3.2.2"
istanbul-lib-report: "npm:^3.0.1"
@@ -5127,36 +5127,36 @@ __metadata:
magicast: "npm:^0.5.2"
obug: "npm:^2.1.1"
std-env: "npm:^4.0.0-rc.1"
tinyrainbow: "npm:^3.1.0"
tinyrainbow: "npm:^3.0.3"
peerDependencies:
"@vitest/browser": 4.1.2
vitest: 4.1.2
"@vitest/browser": 4.1.1
vitest: 4.1.1
peerDependenciesMeta:
"@vitest/browser":
optional: true
checksum: 10/2a38252da937894dfd47a20839714cd49deb8ea0b8289fe25ba17b6677b99dc9b695e4c689b1d6532f19e0d1b81dbac2cf555f82a0ae75abf490dd4107407206
checksum: 10/e5873ac0a40fa34772a68f448910cc1d77c97f2c8d1701adcb1d33411d443aa652b851aacbfc99175c3de136fa038eb83f6321321e1dc371999f3dc96999dd69
languageName: node
linkType: hard
"@vitest/expect@npm:4.1.2":
version: 4.1.2
resolution: "@vitest/expect@npm:4.1.2"
"@vitest/expect@npm:4.1.1":
version: 4.1.1
resolution: "@vitest/expect@npm:4.1.1"
dependencies:
"@standard-schema/spec": "npm:^1.1.0"
"@types/chai": "npm:^5.2.2"
"@vitest/spy": "npm:4.1.2"
"@vitest/utils": "npm:4.1.2"
"@vitest/spy": "npm:4.1.1"
"@vitest/utils": "npm:4.1.1"
chai: "npm:^6.2.2"
tinyrainbow: "npm:^3.1.0"
checksum: 10/536c5a8903927e324bbb66967be4e0ec2ec4ff6234f0b8fe20987841b0705c931c7e3ce2e61c7665f4ded65ba736de6cda8d2d37ee114efeedb187ca5d597ea1
tinyrainbow: "npm:^3.0.3"
checksum: 10/eb74aee01c3c1be58aeb829be6b112600ff34703f2c247ad993db10375d9af87d03c294c485fa6f56754b8af130cc600b397eca081c29d1a2a36b8286e3d0fbd
languageName: node
linkType: hard
"@vitest/mocker@npm:4.1.2":
version: 4.1.2
resolution: "@vitest/mocker@npm:4.1.2"
"@vitest/mocker@npm:4.1.1":
version: 4.1.1
resolution: "@vitest/mocker@npm:4.1.1"
dependencies:
"@vitest/spy": "npm:4.1.2"
"@vitest/spy": "npm:4.1.1"
estree-walker: "npm:^3.0.3"
magic-string: "npm:^0.30.21"
peerDependencies:
@@ -5167,56 +5167,56 @@ __metadata:
optional: true
vite:
optional: true
checksum: 10/1d7976e19ef168357aba2ca41cd8db86236a98dfb2209bd3152a3a20e9a5b8cbfd8f73356c43a934b384d3b4c7a63835fb1037d3f56a7824faa838331eaa214e
checksum: 10/76a50f0af8d5e8ffaa29a944be6ac7acade9fd48d9ddd766a0a27ccbeeb80e256845be7f99c383fe235a9edf66d6801ae698505b577060068f181ec25f3e156a
languageName: node
linkType: hard
"@vitest/pretty-format@npm:4.1.2":
version: 4.1.2
resolution: "@vitest/pretty-format@npm:4.1.2"
"@vitest/pretty-format@npm:4.1.1":
version: 4.1.1
resolution: "@vitest/pretty-format@npm:4.1.1"
dependencies:
tinyrainbow: "npm:^3.1.0"
checksum: 10/a07a6023c52b25be5c75fc05bb3317629390cc1b50eae6cbea91ba4c13193ec88e54abaa56b46b40ddb8a6a4558d667f2ba0e1cf2ee2d0e32b463244f3002aa7
tinyrainbow: "npm:^3.0.3"
checksum: 10/89c260f8361ce11345677ff5f1b99549ad4bde9a38b329885cb20815461b41c7ea6425e4822a7ecdf4ead536cc0dc8757f3a8387f5e9982c741cbb3c8b3eb0cb
languageName: node
linkType: hard
"@vitest/runner@npm:4.1.2":
version: 4.1.2
resolution: "@vitest/runner@npm:4.1.2"
"@vitest/runner@npm:4.1.1":
version: 4.1.1
resolution: "@vitest/runner@npm:4.1.1"
dependencies:
"@vitest/utils": "npm:4.1.2"
"@vitest/utils": "npm:4.1.1"
pathe: "npm:^2.0.3"
checksum: 10/13fd019a63ee3225420474cbd1ca0ae7c5c2dcdd241f2a958ca45731c10de36131f15303ae8ab1196133ec4e955b7c6de658c7b5e19736d550f310c8195fa9b2
checksum: 10/65a4374a4385d2ffccf98110ba7a7521cf9e90ec68b93901f1d5b07f5b574a17fb26e8631d548f703ddaf4ded09b91dc05e4415a986d6e87556689385337c5e7
languageName: node
linkType: hard
"@vitest/snapshot@npm:4.1.2":
version: 4.1.2
resolution: "@vitest/snapshot@npm:4.1.2"
"@vitest/snapshot@npm:4.1.1":
version: 4.1.1
resolution: "@vitest/snapshot@npm:4.1.1"
dependencies:
"@vitest/pretty-format": "npm:4.1.2"
"@vitest/utils": "npm:4.1.2"
"@vitest/pretty-format": "npm:4.1.1"
"@vitest/utils": "npm:4.1.1"
magic-string: "npm:^0.30.21"
pathe: "npm:^2.0.3"
checksum: 10/9d124412dbe44db43ca5277180bf5fe5dad7373218a177830bba631b53d225f7d4de368a20d6f5740ec07402e9e4dd179609db2b2f691d2d8b02f1bdbfd8c1a3
checksum: 10/2f347abfeedb4c2aacad8b0db472842383c5369ed972b65634f327d31c91adc96055426b0dbedcc3275392a57561cffc28d76c526044368fabd1f9ca28e25572
languageName: node
linkType: hard
"@vitest/spy@npm:4.1.2":
version: 4.1.2
resolution: "@vitest/spy@npm:4.1.2"
checksum: 10/e20e417ac430fee34e4be58802b2eb31e1c1163296a8921c0878be14e1ae77c7a7cae1b9b515d56fe623e05ee21b092aff7eb5e0d412f656650b72ecd02bb30a
"@vitest/spy@npm:4.1.1":
version: 4.1.1
resolution: "@vitest/spy@npm:4.1.1"
checksum: 10/50dbb99bc4f49b8779dbbe258c3665aa123720560942ec0db18e983abdea9094c54ce137f627eeddc66460f9c6631df273dbf56109d813eca74a85c2188a4548
languageName: node
linkType: hard
"@vitest/utils@npm:4.1.2":
version: 4.1.2
resolution: "@vitest/utils@npm:4.1.2"
"@vitest/utils@npm:4.1.1":
version: 4.1.1
resolution: "@vitest/utils@npm:4.1.1"
dependencies:
"@vitest/pretty-format": "npm:4.1.2"
"@vitest/pretty-format": "npm:4.1.1"
convert-source-map: "npm:^2.0.0"
tinyrainbow: "npm:^3.1.0"
checksum: 10/854decf0eb639758d012c9aa53c3d7aed547e37c05ece6704d5f53035be77f704a24973ed95089926e1768c0b55902d42c4438660788e7a0f0e80d0fda1c713b
tinyrainbow: "npm:^3.0.3"
checksum: 10/11757c339d2942c43ea8bc105a02fda82696cfd78ad70078d4f0784a8699ff8fb811415a73b955291c71fe2b50a822951b42ebcbb2ccc5180fc5757b2eb598a8
languageName: node
linkType: hard
@@ -8900,7 +8900,7 @@ __metadata:
"@rsdoctor/rspack-plugin": "npm:1.5.5"
"@rspack/core": "npm:1.7.10"
"@rspack/dev-server": "npm:1.2.1"
"@swc/helpers": "npm:0.5.20"
"@swc/helpers": "npm:0.5.19"
"@thomasloven/round-slider": "npm:0.6.0"
"@tsparticles/engine": "npm:3.9.1"
"@tsparticles/preset-links": "npm:3.2.0"
@@ -8922,7 +8922,7 @@ __metadata:
"@types/tar": "npm:7.0.87"
"@types/webspeechapi": "npm:0.0.29"
"@vibrant/color": "npm:4.0.4"
"@vitest/coverage-v8": "npm:4.1.2"
"@vitest/coverage-v8": "npm:4.1.1"
"@webcomponents/scoped-custom-element-registry": "npm:0.0.10"
"@webcomponents/webcomponentsjs": "npm:2.8.0"
babel-loader: "npm:10.1.1"
@@ -9005,7 +9005,7 @@ __metadata:
typescript: "npm:5.9.3"
typescript-eslint: "npm:8.57.2"
vite-tsconfig-paths: "npm:6.1.1"
vitest: "npm:4.1.2"
vitest: "npm:4.1.1"
webpack-stats-plugin: "npm:1.1.3"
webpackbar: "npm:7.0.0"
weekstart: "npm:2.0.0"
@@ -13608,10 +13608,10 @@ __metadata:
languageName: node
linkType: hard
"tinyrainbow@npm:^3.1.0":
version: 3.1.0
resolution: "tinyrainbow@npm:3.1.0"
checksum: 10/4c2c01dde1e5bb9a74973daaae141d4d733d246280b2f9a7f6a9e7dd8e940d48b2580a6086125278777897bc44635d6ccec5f9f563c2179dd2129f4542d0ec05
"tinyrainbow@npm:^3.0.3":
version: 3.0.3
resolution: "tinyrainbow@npm:3.0.3"
checksum: 10/169cc63c15e1378674180f3207c82c05bfa58fc79992e48792e8d97b4b759012f48e95297900ede24a81f0087cf329a0d85bb81109739eacf03c650127b3f6c1
languageName: node
linkType: hard
@@ -14321,17 +14321,17 @@ __metadata:
languageName: node
linkType: hard
"vitest@npm:4.1.2":
version: 4.1.2
resolution: "vitest@npm:4.1.2"
"vitest@npm:4.1.1":
version: 4.1.1
resolution: "vitest@npm:4.1.1"
dependencies:
"@vitest/expect": "npm:4.1.2"
"@vitest/mocker": "npm:4.1.2"
"@vitest/pretty-format": "npm:4.1.2"
"@vitest/runner": "npm:4.1.2"
"@vitest/snapshot": "npm:4.1.2"
"@vitest/spy": "npm:4.1.2"
"@vitest/utils": "npm:4.1.2"
"@vitest/expect": "npm:4.1.1"
"@vitest/mocker": "npm:4.1.1"
"@vitest/pretty-format": "npm:4.1.1"
"@vitest/runner": "npm:4.1.1"
"@vitest/snapshot": "npm:4.1.1"
"@vitest/spy": "npm:4.1.1"
"@vitest/utils": "npm:4.1.1"
es-module-lexer: "npm:^2.0.0"
expect-type: "npm:^1.3.0"
magic-string: "npm:^0.30.21"
@@ -14342,17 +14342,17 @@ __metadata:
tinybench: "npm:^2.9.0"
tinyexec: "npm:^1.0.2"
tinyglobby: "npm:^0.2.15"
tinyrainbow: "npm:^3.1.0"
tinyrainbow: "npm:^3.0.3"
vite: "npm:^6.0.0 || ^7.0.0 || ^8.0.0"
why-is-node-running: "npm:^2.3.0"
peerDependencies:
"@edge-runtime/vm": "*"
"@opentelemetry/api": ^1.9.0
"@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0
"@vitest/browser-playwright": 4.1.2
"@vitest/browser-preview": 4.1.2
"@vitest/browser-webdriverio": 4.1.2
"@vitest/ui": 4.1.2
"@vitest/browser-playwright": 4.1.1
"@vitest/browser-preview": 4.1.1
"@vitest/browser-webdriverio": 4.1.1
"@vitest/ui": 4.1.1
happy-dom: "*"
jsdom: "*"
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -14379,7 +14379,7 @@ __metadata:
optional: false
bin:
vitest: vitest.mjs
checksum: 10/6b037387e59d403f6570f887f6ac96b81ff6e768dbd02d32a812ddff5bdebef022dd6d9f20b84fb9535866e0c5dbdf80e6705cc428b6a8f8a8e67e1335235848
checksum: 10/2dc81153729c57e6b3ad0aaa920a4f026b717df12741c4c520b8cd49b92fc6e21e9af1ac7568249383521311fcde28f1dfd79a666fd679316016958838249b1b
languageName: node
linkType: hard