Compare commits

..

55 Commits

Author SHA1 Message Date
Aidan Timson
b5d61d4041 Format 2026-03-24 10:50:30 +00:00
Aidan Timson
79780b111c Remove unnecessary copy
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-24 10:50:30 +00:00
Aidan Timson
a592a5f222 Dont force ltr 2026-03-24 10:50:30 +00:00
Aidan Timson
2ac8bf9179 Cache date formatting 2026-03-24 10:50:30 +00:00
Aidan Timson
fd21dd2fd4 Reflect property for style usage 2026-03-24 10:50:30 +00:00
Aidan Timson
370ccd95da Remove JS tick for analog clock 2026-03-24 10:50:30 +00:00
Aidan Timson
ed2161effd Inline 2026-03-24 10:50:30 +00:00
Aidan Timson
19d902afc6 Improve typing 2026-03-24 10:50:30 +00:00
Aidan Timson
672d235c3e Remove modern/legacy support diff 2026-03-24 10:50:30 +00:00
Aidan Timson
5246ce3f72 Fix 2026-03-24 10:50:29 +00:00
Aidan Timson
c83924efa7 Refactor 2026-03-24 10:50:29 +00:00
Aidan Timson
bdbcec4d90 Update description 2026-03-24 10:50:29 +00:00
Aidan Timson
a074c80ec3 Fix value render bug 2026-03-24 10:50:29 +00:00
Aidan Timson
3795ad1253 Cleanup 2026-03-24 10:50:29 +00:00
Aidan Timson
7bb466a75b Remove conditional render on seperators 2026-03-24 10:50:29 +00:00
Aidan Timson
bff2514eed Format 2026-03-24 10:50:29 +00:00
Aidan Timson
602d41b31d Move section 2026-03-24 10:50:29 +00:00
Aidan Timson
85d10cf982 Fix 1st value as array 2026-03-24 10:50:29 +00:00
Aidan Timson
a3ff3346db Split date and clock to avoid layout shifts 2026-03-24 10:50:29 +00:00
Aidan Timson
38a314ced4 Margin 2026-03-24 10:50:29 +00:00
Aidan Timson
2cf7452ed1 Reduce line height 2026-03-24 10:50:29 +00:00
Aidan Timson
ae97cc1c8d Use singular section name 2026-03-24 10:50:29 +00:00
Aidan Timson
65bba30266 Add new line seperator 2026-03-24 10:50:29 +00:00
Aidan Timson
8ee3544a32 Swap section in picker values 2026-03-24 10:50:29 +00:00
Aidan Timson
fcddf8f548 Rename 2026-03-24 10:50:29 +00:00
Aidan Timson
c7824d4059 Section in picker values 2026-03-24 10:50:29 +00:00
Aidan Timson
8c4f5206b1 Preview in secondary 2026-03-24 10:50:29 +00:00
Aidan Timson
cc2a7972fc Remove section from labels 2026-03-24 10:50:29 +00:00
Aidan Timson
33079bb12c Group sections 2026-03-24 10:50:29 +00:00
Aidan Timson
34152e522e Date formatter C 2026-03-24 10:50:29 +00:00
Aidan Timson
a0dc331056 Date formatter B 2026-03-24 10:47:32 +00:00
Aidan Timson
4a56c1404f Date formatter A 2026-03-24 10:47:32 +00:00
Aidan Timson
7e7845853d Use local date var 2026-03-24 10:47:32 +00:00
Aidan Timson
f8fe7a7d82 Cleanup old translation 2026-03-24 10:47:32 +00:00
Aidan Timson
8b40b55324 Scale small based on date length 2026-03-24 10:47:32 +00:00
Aidan Timson
ab55d1fdde Sizing (CSS Impl) 2026-03-24 10:47:32 +00:00
Aidan Timson
597099f153 Sizing (JS Impl) 2026-03-24 10:47:32 +00:00
Aidan Timson
40ba2ade58 Format 2026-03-24 10:47:32 +00:00
Aidan Timson
901fa4cdda Add 2026-03-24 10:47:32 +00:00
Aidan Timson
edf007718a Improve 2026-03-24 10:47:32 +00:00
Aidan Timson
5abaeea1f9 Type 2026-03-24 10:47:32 +00:00
Aidan Timson
1ce0a7eab2 Setup 2026-03-24 10:47:32 +00:00
Aidan Timson
c0c02eb548 Match 2026-03-24 10:47:32 +00:00
Aidan Timson
18d5b84a02 Setup analog clock 2026-03-24 10:47:32 +00:00
Aidan Timson
ebc58f025a Add date to digital clock 2026-03-24 10:47:32 +00:00
Aidan Timson
cb2758d868 Setup 2026-03-24 10:47:32 +00:00
Aidan Timson
5bbfa36228 Support more-info-view query param (#30282)
* Support more-info-view query param

* Remove unintended subview logic and add details view

* Reset childView
2026-03-24 11:26:08 +02:00
Bram Kragten
a8070b322c Add numeric threshold selector (#30284)
* wip

* add numeric threshold selector

* clean up

* review optimize
2026-03-24 10:59:07 +02:00
Jan-Philipp Benecke
9cbc44123e Enhance delete entity confirmation dialog with detailed information (#30293) 2026-03-24 09:43:46 +01:00
Wendelin
c8f4c892f9 Migrate ha-multi-textfield, ha-selector-text to ha-input and update to use new input components (#30280)
* Migrate ha-multi-textfield to ha-input-multi and update ha-selector-text to use new input components

* Review

* Update src/components/input/ha-input-multi.ts

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-03-24 07:04:39 +00:00
Simon Lamon
40b9f9dccb Gauge card improvements (#30149)
* Gauge card improvements

* Decrease stroke width

* Feedback

* Remove show more, move text to bottom

* Remove _handleMoreInfo method from hui-gauge-card

Removed the _handleMoreInfo method from hui-gauge-card.

* Remove unused fireEvent import from hui-gauge-card

Removed unused import for fireEvent.

* Fix up padding

* Better alignment from previous card

* Decrease padding

* Apply suggestions from code review

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-03-24 06:56:29 +00:00
Aidan Timson
823c222a55 Fix app info descriptions and metrics (#30287)
* Improve app info descriptions

* Space tokens on rest of files

* Don't display empty value
2026-03-23 18:36:29 +01:00
Paul Bottein
02acd2996c Allow boolean option to section background (#30289) 2026-03-23 18:32:53 +01:00
renovate[bot]
c462fc0639 Update formatjs monorepo (#30256)
* Update formatjs monorepo

* Fixup convert locale script

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-03-23 17:46:08 +01:00
renovate[bot]
903553dab9 Update dependency marked to v17.0.5 (#30286)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-23 16:12:59 +00:00
38 changed files with 2462 additions and 1510 deletions

View File

@@ -40,18 +40,24 @@ const convertToJSON = async (
throw e;
}
// Convert to JSON
const parts = localeData.split("} else {");
const firstBlock = parts[0];
const obj = INTL_POLYFILLS[pkg];
const dataRegex = new RegExp(
`Intl\\.${obj}\\.${addFunc}\\((?<data>.*)\\)`,
"s"
);
localeData = localeData.match(dataRegex)?.groups?.data;
localeData = firstBlock.match(dataRegex)?.groups?.data;
if (!localeData) {
throw Error(`Failed to extract data for language ${lang} from ${pkg}`);
}
// Parse to validate JSON, then stringify to minify
localeData = JSON.stringify(JSON.parse(localeData));
await writeFile(join(outDir, `${pkg}/${lang}.json`), localeData);
try {
localeData = JSON.stringify(JSON.parse(localeData));
await writeFile(join(outDir, `${pkg}/${lang}.json`), localeData);
} catch (e) {
throw Error(`Failed to parse JSON for language ${lang} from ${pkg}: ${e}`);
}
};
gulp.task("clean-locale-data", async () => deleteSync([outDir]));

View File

@@ -37,15 +37,15 @@
"@codemirror/view": "6.40.0",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.2.6",
"@formatjs/intl-displaynames": "7.2.3",
"@formatjs/intl-durationformat": "0.10.2",
"@formatjs/intl-datetimeformat": "7.3.1",
"@formatjs/intl-displaynames": "7.3.1",
"@formatjs/intl-durationformat": "0.10.3",
"@formatjs/intl-getcanonicallocales": "3.2.2",
"@formatjs/intl-listformat": "8.2.3",
"@formatjs/intl-locale": "5.2.2",
"@formatjs/intl-numberformat": "9.2.4",
"@formatjs/intl-pluralrules": "6.2.4",
"@formatjs/intl-relativetimeformat": "12.2.4",
"@formatjs/intl-listformat": "8.3.1",
"@formatjs/intl-locale": "5.3.1",
"@formatjs/intl-numberformat": "9.3.1",
"@formatjs/intl-pluralrules": "6.3.1",
"@formatjs/intl-relativetimeformat": "12.3.1",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
@@ -108,7 +108,7 @@
"hls.js": "1.6.15",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "11.1.3",
"intl-messageformat": "11.2.0",
"js-yaml": "4.1.1",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
@@ -116,7 +116,7 @@
"lit": "3.3.2",
"lit-html": "3.3.2",
"luxon": "3.7.2",
"marked": "17.0.4",
"marked": "17.0.5",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",

View File

@@ -2,7 +2,7 @@ import { LitElement, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import type { HomeAssistant } from "../types";
import "./ha-multi-textfield";
import "./input/ha-input-multi";
@customElement("ha-aliases-editor")
class AliasesEditor extends LitElement {
@@ -20,7 +20,7 @@ class AliasesEditor extends LitElement {
}
return html`
<ha-multi-textfield
<ha-input-multi
.hass=${this.hass}
.value=${this.aliases}
.disabled=${this.disabled}
@@ -32,7 +32,7 @@ class AliasesEditor extends LitElement {
item-index
@value-changed=${this._aliasesChanged}
>
</ha-multi-textfield>
</ha-input-multi>
`;
}

View File

@@ -0,0 +1,522 @@
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { ensureArray } from "../common/array/ensure-array";
import { resolveTimeZone } from "../common/datetime/resolve-time-zone";
import { fireEvent } from "../common/dom/fire_event";
import {
CLOCK_CARD_DATE_PARTS,
formatClockCardDate,
} from "../panels/lovelace/cards/clock/clock-date-format";
import type { ClockCardDatePart } from "../panels/lovelace/cards/types";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./chips/ha-assist-chip";
import "./chips/ha-chip-set";
import "./chips/ha-input-chip";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-input-helper-text";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import "./ha-sortable";
type ClockDatePartSection = "weekday" | "day" | "month" | "year" | "separator";
type ClockDateSeparatorPart = Extract<
ClockCardDatePart,
"separator-dash" | "separator-slash" | "separator-dot" | "separator-new-line"
>;
const CLOCK_DATE_PART_SECTION_ORDER: readonly ClockDatePartSection[] = [
"day",
"month",
"year",
"weekday",
"separator",
];
const CLOCK_DATE_SEPARATOR_VALUES: Record<ClockDateSeparatorPart, string> = {
"separator-dash": "-",
"separator-slash": "/",
"separator-dot": ".",
"separator-new-line": "",
};
const getClockDatePartSection = (
part: ClockCardDatePart
): ClockDatePartSection => {
if (part.startsWith("weekday-")) {
return "weekday";
}
if (part.startsWith("day-")) {
return "day";
}
if (part.startsWith("month-")) {
return "month";
}
if (part.startsWith("year-")) {
return "year";
}
return "separator";
};
interface ClockDatePartSectionData {
id: ClockDatePartSection;
title: string;
items: PickerComboBoxItem[];
}
interface ClockDatePartValueItem {
key: string;
item: string;
idx: number;
}
@customElement("ha-clock-date-format-picker")
export class HaClockDateFormatPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public disabled = false;
@property({ type: Boolean }) public required = false;
@property() public label?: string;
@property() public value?: string[] | string;
@property() public helper?: string;
@query("ha-generic-picker", true) private _picker?: HaGenericPicker;
private _editIndex?: number;
protected render() {
const value = this._value;
const valueItems = this._getValueItems(value);
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.value=${this._getPickerValue()}
.sections=${this._getSections(this.hass.locale.language)}
.getItems=${this._getItems}
@value-changed=${this._pickerValueChanged}
>
<div slot="field" class="container">
<ha-sortable
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set>
${repeat(
valueItems,
(entry: ClockDatePartValueItem) => entry.key,
({ item, idx }) => this._renderValueChip(item, idx)
)}
${this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this.hass.localize("ui.common.add")}
class="add"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-assist-chip>
`}
</ha-chip-set>
</ha-sortable>
</div>
</ha-generic-picker>
${this._renderHelper()}
`;
}
private _renderHelper() {
return this.helper
? html`
<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>
`
: nothing;
}
private _getValueItems = memoizeOne(
(value: string[]): ClockDatePartValueItem[] => {
const occurrences = new Map<string, number>();
return value.map((item, idx) => {
const occurrence = occurrences.get(item) ?? 0;
occurrences.set(item, occurrence + 1);
return {
key: `${item}:${occurrence}`,
item,
idx,
};
});
}
);
private _renderValueChip(item: string, idx: number) {
const label = this._getItemLabel(item, this.hass.locale.language);
const isValid = !!label;
return html`
<ha-input-chip
data-idx=${idx}
@remove=${this._removeItem}
@click=${this._editItem}
.label=${label ?? item}
.selected=${!this.disabled}
.disabled=${this.disabled}
class=${!isValid ? "invalid" : ""}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</ha-input-chip>
`;
}
private async _addItem(ev: Event) {
ev.stopPropagation();
if (this.disabled) {
return;
}
this._editIndex = undefined;
await this.updateComplete;
await this._picker?.open();
}
private async _editItem(ev: Event) {
ev.stopPropagation();
if (this.disabled) {
return;
}
const idx = parseInt(
(ev.currentTarget as HTMLElement).dataset.idx ?? "",
10
);
this._editIndex = idx;
await this.updateComplete;
await this._picker?.open();
}
private get _value() {
return !this.value ? [] : ensureArray(this.value);
}
private _toValue = memoizeOne((value: string[]): string[] | undefined =>
value.length === 0 ? undefined : value
);
private _buildSections = memoizeOne(
(language: string): ClockDatePartSectionData[] => {
const itemsBySection: Record<ClockDatePartSection, PickerComboBoxItem[]> =
{
weekday: [],
day: [],
month: [],
year: [],
separator: [],
};
const previewDate = new Date();
const previewTimeZone = resolveTimeZone(
this.hass.locale.time_zone,
this.hass.config.time_zone
);
CLOCK_CARD_DATE_PARTS.forEach((part) => {
const section = getClockDatePartSection(part);
const label =
this.hass.localize(
`ui.panel.lovelace.editor.card.clock.date.parts.${part}`
) ?? part;
const secondary =
section === "separator"
? CLOCK_DATE_SEPARATOR_VALUES[part as ClockDateSeparatorPart]
: formatClockCardDate(
previewDate,
{ parts: [part] },
language,
previewTimeZone
);
itemsBySection[section].push({
id: part,
primary: label,
secondary,
sorting_label: label,
});
});
return CLOCK_DATE_PART_SECTION_ORDER.map((section) => ({
id: section,
title:
this.hass.localize(
`ui.panel.lovelace.editor.card.clock.date.sections.${section}`
) ?? section,
items: itemsBySection[section],
})).filter((section) => section.items.length > 0);
}
);
private _getSections = memoizeOne(
(_language: string): { id: string; label: string }[] =>
this._buildSections(_language).map((section) => ({
id: section.id,
label: section.title,
}))
);
private _getItems = (
searchString?: string,
section?: string
): (PickerComboBoxItem | string)[] => {
const normalizedSearch = searchString?.trim().toLowerCase();
const sections = this._buildSections(this.hass.locale.language)
.map((sectionData) => {
if (!normalizedSearch) {
return sectionData;
}
return {
...sectionData,
items: sectionData.items.filter(
(item) =>
item.primary.toLowerCase().includes(normalizedSearch) ||
item.secondary?.toLowerCase().includes(normalizedSearch) ||
item.id.toLowerCase().includes(normalizedSearch)
),
};
})
.filter((sectionData) => sectionData.items.length > 0);
if (section) {
return (
sections.find((candidate) => candidate.id === section)?.items || []
);
}
const groupedItems: (PickerComboBoxItem | string)[] = [];
sections.forEach((sectionData) => {
groupedItems.push(sectionData.title, ...sectionData.items);
});
return groupedItems;
};
private _getItemLabel = memoizeOne(
(value: string, language: string): string | undefined => {
const sections = this._buildSections(language);
for (const section of sections) {
const item = section.items.find((candidate) => candidate.id === value);
if (item) {
if (section.id === "separator") {
if (value === "separator-new-line") {
return item.primary;
}
return item.secondary ?? item.primary;
}
return `${item.secondary} [${item.primary} ${section.title}]`;
}
}
return undefined;
}
);
private _getPickerValue(): string | undefined {
if (this._editIndex != null) {
return this._value[this._editIndex];
}
return undefined;
}
private async _moveItem(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const value = this._value;
const newValue = value.concat();
const element = newValue.splice(oldIndex, 1)[0];
newValue.splice(newIndex, 0, element);
this._setValue(newValue);
}
private async _removeItem(ev: Event) {
ev.preventDefault();
ev.stopPropagation();
const idx = parseInt(
(ev.currentTarget as HTMLElement).dataset.idx ?? "",
10
);
if (Number.isNaN(idx)) {
return;
}
const value = [...this._value];
value.splice(idx, 1);
if (this._editIndex !== undefined) {
if (this._editIndex === idx) {
this._editIndex = undefined;
} else if (this._editIndex > idx) {
this._editIndex -= 1;
}
}
this._setValue(value);
}
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
const value = ev.detail.value;
if (this.disabled || !value) {
return;
}
const newValue = [...this._value];
if (this._editIndex != null) {
newValue[this._editIndex] = value;
this._editIndex = undefined;
} else {
newValue.push(value);
}
this._setValue(newValue);
if (this._picker) {
this._picker.value = undefined;
}
}
private _setValue(value: string[]) {
const newValue = this._toValue(value);
this.value = newValue;
fireEvent(this, "value-changed", {
value: newValue,
});
}
static styles = css`
:host {
position: relative;
width: 100%;
}
.container {
position: relative;
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-radius: var(--ha-border-radius-sm);
border-end-end-radius: var(--ha-border-radius-square);
border-end-start-radius: var(--ha-border-radius-square);
}
.container:after {
display: block;
content: "";
position: absolute;
pointer-events: none;
bottom: 0;
left: 0;
right: 0;
height: 1px;
width: 100%;
background-color: var(
--mdc-text-field-idle-line-color,
rgba(0, 0, 0, 0.42)
);
transition:
height 180ms ease-in-out,
background-color 180ms ease-in-out;
}
:host([disabled]) .container:after {
background-color: var(
--mdc-text-field-disabled-line-color,
rgba(0, 0, 0, 0.42)
);
}
.container:focus-within:after {
height: 2px;
background-color: var(--mdc-theme-primary);
}
label {
display: block;
margin: 0 0 var(--ha-space-2);
}
.add {
order: 1;
}
ha-chip-set {
padding: var(--ha-space-2);
}
.invalid {
text-decoration: line-through;
}
.sortable-fallback {
display: none;
opacity: 0;
}
.sortable-ghost {
opacity: 0.4;
}
.sortable-drag {
cursor: grabbing;
}
ha-input-helper-text {
display: block;
margin: var(--ha-space-2) 0 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-clock-date-format-picker": HaClockDateFormatPicker;
}
}

View File

@@ -76,10 +76,12 @@ export class HaGauge extends LitElement {
const arcRadius = 40;
const arcLength = Math.PI * arcRadius;
const valueAngle = getAngle(this.value, this.min, this.max);
const strokeOffset = arcLength * (1 - valueAngle / 180);
const strokeOffset = this._updated
? arcLength * (1 - valueAngle / 180)
: arcLength;
return svg`
<svg viewBox="-50 -50 100 60" class="gauge">
<svg viewBox="-50 -50 100 55" class="gauge">
<path
class="levels-base"
d="M -40 0 A 40 40 0 0 1 40 0"
@@ -181,7 +183,7 @@ export class HaGauge extends LitElement {
<text
class="value-text"
x="0"
y="-10"
y="-5"
dominant-baseline="middle"
text-anchor="middle"
>
@@ -222,22 +224,22 @@ export class HaGauge extends LitElement {
.levels-base {
fill: none;
stroke: var(--primary-background-color);
stroke-width: 10;
stroke-width: 8;
stroke-linecap: round;
}
.level {
fill: none;
stroke-width: 10;
stroke-width: 8;
stroke-linecap: butt;
}
.value {
fill: none;
stroke-width: 10;
stroke-width: 8;
stroke: var(--gauge-color);
stroke-linecap: round;
transition: all 1s ease 0s;
transition: stroke-dashoffset 1s ease 0s;
}
.needle {
@@ -249,6 +251,7 @@ export class HaGauge extends LitElement {
}
.value-text {
font-size: var(--ha-font-size-l);
fill: var(--primary-text-color);
direction: ltr;
}

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

@@ -0,0 +1,472 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import {
mdiArrowCollapseVertical,
mdiArrowExpandVertical,
mdiGreaterThan,
mdiLessThan,
} from "@mdi/js";
import { fireEvent } from "../../common/dom/fire_event";
import type { NumericThresholdSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-button-toggle-group";
import "../ha-input-helper-text";
import "../ha-select";
import "./ha-selector";
type ThresholdType = "above" | "below" | "between" | "outside";
interface ThresholdValueEntry {
active_choice?: string;
number?: number;
entity?: string;
unit_of_measurement?: string;
}
interface NumericThresholdValue {
type: ThresholdType;
value?: ThresholdValueEntry;
value_min?: ThresholdValueEntry;
value_max?: ThresholdValueEntry;
}
@customElement("ha-selector-numeric_threshold")
export class HaNumericThresholdSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: NumericThresholdSelector;
@property({ attribute: false }) public value?: NumericThresholdValue;
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
@state() private _type?: ThresholdType;
protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has("value")) {
this._type = this.value?.type || "above";
}
}
private _getUnitOptions() {
return this.selector.numeric_threshold?.unit_of_measurement;
}
private _getEntityFilter() {
const baseFilter = this.selector.numeric_threshold?.entity;
const configuredUnits =
this.selector.numeric_threshold?.unit_of_measurement;
if (!configuredUnits) {
return baseFilter;
}
if (Array.isArray(baseFilter)) {
return baseFilter.map((f) => ({
...f,
unit_of_measurement: configuredUnits,
}));
}
if (baseFilter) {
return { ...baseFilter, unit_of_measurement: configuredUnits };
}
return { unit_of_measurement: configuredUnits };
}
protected render() {
const type = this._type || "above";
const showSingleValue = type === "above" || type === "below";
const showRangeValues = type === "between" || type === "outside";
const unitOptions = this._getUnitOptions();
const typeOptions = [
{
value: "above",
label: this.hass.localize(
"ui.components.selectors.numeric_threshold.above"
),
iconPath: mdiGreaterThan,
},
{
value: "below",
label: this.hass.localize(
"ui.components.selectors.numeric_threshold.below"
),
iconPath: mdiLessThan,
},
{
value: "between",
label: this.hass.localize(
"ui.components.selectors.numeric_threshold.in_range"
),
iconPath: mdiArrowCollapseVertical,
},
{
value: "outside",
label: this.hass.localize(
"ui.components.selectors.numeric_threshold.outside_range"
),
iconPath: mdiArrowExpandVertical,
},
];
const choiceToggleButtons = [
{
label: this.hass.localize(
"ui.components.selectors.numeric_threshold.number"
),
value: "number",
},
{
label: this.hass.localize(
"ui.components.selectors.numeric_threshold.entity"
),
value: "entity",
},
];
return html`
<div class="container">
${this.label
? html`<label>${this.label}${this.required ? "*" : ""}</label>`
: nothing}
<div class="inputs">
<ha-select
.label=${this.hass.localize(
"ui.components.selectors.numeric_threshold.type"
)}
.value=${type}
.options=${typeOptions}
.disabled=${this.disabled}
@selected=${this._typeChanged}
></ha-select>
${showSingleValue
? this._renderValueRow(
this.hass.localize(
type === "above"
? "ui.components.selectors.numeric_threshold.above"
: "ui.components.selectors.numeric_threshold.below"
),
this.value?.value,
this._valueChanged,
this._valueChoiceChanged,
this._unitChanged,
unitOptions,
choiceToggleButtons
)
: nothing}
${showRangeValues
? html`
${this._renderValueRow(
this.hass.localize(
"ui.components.selectors.numeric_threshold.from"
),
this.value?.value_min,
this._valueMinChanged,
this._valueMinChoiceChanged,
this._unitMinChanged,
unitOptions,
choiceToggleButtons
)}
${this._renderValueRow(
this.hass.localize(
"ui.components.selectors.numeric_threshold.to"
),
this.value?.value_max,
this._valueMaxChanged,
this._valueMaxChoiceChanged,
this._unitMaxChanged,
unitOptions,
choiceToggleButtons
)}
`
: nothing}
</div>
${this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing}
</div>
`;
}
private _renderUnitSelect(
entry: ThresholdValueEntry | undefined,
handler: (ev: CustomEvent) => void,
unitOptions: readonly string[]
) {
if (unitOptions.length <= 1) {
return nothing;
}
const mappedUnitOptions = unitOptions.map((unit) => ({
value: unit,
label: unit,
}));
const unitLabel = this.hass.localize(
"ui.components.selectors.numeric_threshold.unit"
);
return html`
<ha-select
class="unit-selector"
.label=${unitLabel}
.value=${entry?.unit_of_measurement || unitOptions[0]}
.options=${mappedUnitOptions}
.disabled=${this.disabled}
@selected=${handler}
></ha-select>
`;
}
private _renderValueRow(
rowLabel: string,
entry: ThresholdValueEntry | undefined,
onValueChanged: (ev: CustomEvent) => void,
onChoiceChanged: (ev: CustomEvent) => void,
onUnitChanged: (ev: CustomEvent) => void,
unitOptions: readonly string[] | undefined,
choiceToggleButtons: { label: string; value: string }[]
) {
const activeChoice = entry?.active_choice ?? "number";
const isEntity = activeChoice === "entity";
const showUnit = !isEntity && !!unitOptions && unitOptions.length > 1;
const innerValue = isEntity ? entry?.entity : entry?.number;
const effectiveUnit = entry?.unit_of_measurement || unitOptions?.[0];
const numberSelector = {
number: {
...this.selector.numeric_threshold?.number,
...(effectiveUnit ? { unit_of_measurement: effectiveUnit } : {}),
},
};
const entitySelector = {
entity: {
filter: this._getEntityFilter(),
},
};
const innerSelector = isEntity ? entitySelector : numberSelector;
return html`
<div class="value-row">
<div class="value-header">
<span class="value-label"
>${rowLabel}${this.required ? "*" : ""}</span
>
<ha-button-toggle-group
size="small"
.buttons=${choiceToggleButtons}
.active=${activeChoice}
.disabled=${this.disabled}
@value-changed=${onChoiceChanged}
></ha-button-toggle-group>
</div>
<div class="value-inputs">
<ha-selector
class="value-selector"
.hass=${this.hass}
.selector=${innerSelector}
.value=${innerValue}
.disabled=${this.disabled}
.required=${this.required}
@value-changed=${onValueChanged}
></ha-selector>
${showUnit
? this._renderUnitSelect(entry, onUnitChanged, unitOptions!)
: nothing}
</div>
</div>
`;
}
private _typeChanged(ev: CustomEvent) {
const value = ev.detail?.value;
if (!value || value === this._type) {
return;
}
this._type = value as ThresholdType;
const newValue: NumericThresholdValue = {
type: this._type,
};
// Preserve values when switching between similar types
if (this._type === "above" || this._type === "below") {
newValue.value = this.value?.value ?? this.value?.value_min;
} else if (this._type === "between" || this._type === "outside") {
newValue.value_min = this.value?.value_min ?? this.value?.value;
newValue.value_max = this.value?.value_max;
}
fireEvent(this, "value-changed", { value: newValue });
}
private _choiceChanged(
field: "value" | "value_min" | "value_max",
ev: CustomEvent
) {
ev.stopPropagation();
const choice = ev.detail?.value as string;
const defaultUnit = this._getUnitOptions()?.[0];
const entry: ThresholdValueEntry = {
...this.value?.[field],
active_choice: choice,
};
if (choice !== "entity" && !entry.unit_of_measurement && defaultUnit) {
entry.unit_of_measurement = defaultUnit;
}
const defaultType = field === "value" ? "above" : "between";
fireEvent(this, "value-changed", {
value: {
...this.value,
type: this._type || defaultType,
[field]: entry,
...(field === "value"
? { value_min: undefined, value_max: undefined }
: { value: undefined }),
},
});
}
private _valueChoiceChanged = (ev: CustomEvent) =>
this._choiceChanged("value", ev);
private _valueMinChoiceChanged = (ev: CustomEvent) =>
this._choiceChanged("value_min", ev);
private _valueMaxChoiceChanged = (ev: CustomEvent) =>
this._choiceChanged("value_max", ev);
// Called when the inner number/entity selector value changes
private _entryChanged(
field: "value" | "value_min" | "value_max",
ev: CustomEvent
) {
ev.stopPropagation();
const activeChoice = this.value?.[field]?.active_choice ?? "number";
const defaultUnit = this._getUnitOptions()?.[0];
const entry: ThresholdValueEntry = {
...this.value?.[field],
active_choice: activeChoice,
[activeChoice]: ev.detail.value,
};
if (
activeChoice !== "entity" &&
!entry.unit_of_measurement &&
defaultUnit
) {
entry.unit_of_measurement = defaultUnit;
}
const defaultType = field === "value" ? "above" : "between";
fireEvent(this, "value-changed", {
value: {
...this.value,
type: this._type || defaultType,
[field]: entry,
...(field === "value"
? { value_min: undefined, value_max: undefined }
: { value: undefined }),
},
});
}
private _valueChanged = (ev: CustomEvent) => this._entryChanged("value", ev);
private _valueMinChanged = (ev: CustomEvent) =>
this._entryChanged("value_min", ev);
private _valueMaxChanged = (ev: CustomEvent) =>
this._entryChanged("value_max", ev);
private _unitFieldChanged(
field: "value" | "value_min" | "value_max",
ev: CustomEvent
) {
const unit = ev.detail?.value;
if (unit === this.value?.[field]?.unit_of_measurement) return;
const activeChoice = this.value?.[field]?.active_choice ?? "number";
const defaultType = field === "value" ? "above" : "between";
fireEvent(this, "value-changed", {
value: {
...this.value,
type: this._type || defaultType,
[field]: {
...this.value?.[field],
active_choice: activeChoice,
unit_of_measurement: unit || undefined,
},
},
});
}
private _unitChanged = (ev: CustomEvent) =>
this._unitFieldChanged("value", ev);
private _unitMinChanged = (ev: CustomEvent) =>
this._unitFieldChanged("value_min", ev);
private _unitMaxChanged = (ev: CustomEvent) =>
this._unitFieldChanged("value_max", ev);
static styles = css`
.container {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
label {
display: block;
font-weight: 500;
margin-bottom: var(--ha-space-1);
}
.inputs,
.value-row {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.value-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.value-label {
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
}
.value-inputs {
display: flex;
gap: var(--ha-space-2);
align-items: flex-end;
}
.value-selector {
flex: 1;
display: block;
}
.unit-selector {
width: 120px;
}
ha-select {
width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-numeric_threshold": HaNumericThresholdSelector;
}
}

View File

@@ -1,14 +1,12 @@
import { mdiEye, mdiEyeOff } from "@mdi/js";
import { LitElement, css, html } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import type { StringSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-icon-button";
import "../ha-multi-textfield";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-textarea";
import "../ha-textfield";
import "../input/ha-input";
import "../input/ha-input-multi";
@customElement("ha-selector-text")
export class HaTextSelector extends LitElement {
@@ -30,9 +28,7 @@ export class HaTextSelector extends LitElement {
@property({ type: Boolean }) public required = true;
@state() private _unmaskedPassword = false;
@query("ha-textfield, ha-textarea") private _input?: HTMLInputElement;
@query("ha-input, ha-textarea") private _input?: HTMLInputElement;
public async focus() {
await this.updateComplete;
@@ -49,8 +45,7 @@ export class HaTextSelector extends LitElement {
protected render() {
if (this.selector.text?.multiple) {
return html`
<ha-multi-textfield
.hass=${this.hass}
<ha-input-multi
.value=${ensureArray(this.value ?? [])}
.disabled=${this.disabled}
.label=${this.label}
@@ -61,7 +56,7 @@ export class HaTextSelector extends LitElement {
.autocomplete=${this.selector.text?.autocomplete}
@value-changed=${this._handleChange}
>
</ha-multi-textfield>
</ha-input-multi>
`;
}
if (this.selector.text?.multiline) {
@@ -81,45 +76,34 @@ export class HaTextSelector extends LitElement {
autogrow
></ha-textarea>`;
}
return html`<ha-textfield
.name=${this.name}
.value=${this.value || ""}
.placeholder=${this.placeholder || ""}
.helper=${this.helper}
helperPersistent
.disabled=${this.disabled}
.type=${this._unmaskedPassword ? "text" : this.selector.text?.type}
@input=${this._handleChange}
@change=${this._handleChange}
.label=${this.label || ""}
.prefix=${this.selector.text?.prefix}
.suffix=${this.selector.text?.type === "password"
? // reserve some space for the icon.
html`<div style="width: 24px"></div>`
: this.selector.text?.suffix}
.required=${this.required}
.autocomplete=${this.selector.text?.autocomplete}
></ha-textfield>
${this.selector.text?.type === "password"
? html`<ha-icon-button
.label=${this.hass?.localize(
this._unmaskedPassword
? "ui.components.selectors.text.hide_password"
: "ui.components.selectors.text.show_password"
) || (this._unmaskedPassword ? "Hide password" : "Show password")}
@click=${this._toggleUnmaskedPassword}
.path=${this._unmaskedPassword ? mdiEyeOff : mdiEye}
></ha-icon-button>`
: ""}`;
return html`<ha-input
.name=${this.name}
.value=${this.value || ""}
.placeholder=${this.placeholder || ""}
.hint=${this.helper}
.disabled=${this.disabled}
.type=${this.selector.text?.type}
@input=${this._handleChange}
@change=${this._handleChange}
.label=${this.label || ""}
.required=${this.required}
.autocomplete=${this.selector.text?.autocomplete}
.passwordToggle=${this.selector.text?.type === "password"}
>
${this.selector.text?.prefix
? html`<span slot="start">${this.selector.text.prefix}</span>`
: nothing}
${this.selector.text?.suffix
? html`<span slot="end">${this.selector.text.suffix}</span>`
: nothing}
</ha-input>`;
}
private _toggleUnmaskedPassword(): void {
this._unmaskedPassword = !this._unmaskedPassword;
}
private _handleChange(ev) {
private _handleChange(ev: ValueChangedEvent<string> | InputEvent) {
ev.stopPropagation();
let value = ev.detail?.value ?? ev.target.value;
let value: string | undefined =
(ev as ValueChangedEvent<string>).detail?.value ??
(ev.target as HTMLInputElement).value;
if (this.value === value) {
return;
}
@@ -139,20 +123,9 @@ export class HaTextSelector extends LitElement {
position: relative;
}
ha-textarea,
ha-textfield {
ha-input {
width: 100%;
}
ha-icon-button {
position: absolute;
top: 8px;
right: 8px;
inset-inline-start: initial;
inset-inline-end: 8px;
--ha-icon-button-size: 40px;
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
direction: var(--direction);
}
`;
}

View File

@@ -0,0 +1,41 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { UiClockDateFormatSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-clock-date-format-picker";
@customElement("ha-selector-ui_clock_date_format")
export class HaSelectorUiClockDateFormat extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: UiClockDateFormatSelector;
@property() public value?: string | string[];
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
protected render() {
return html`
<ha-clock-date-format-picker
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
></ha-clock-date-format-picker>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-ui_clock_date_format": HaSelectorUiClockDateFormat;
}
}

View File

@@ -39,6 +39,7 @@ const LOAD_ELEMENTS = {
language: () => import("./ha-selector-language"),
navigation: () => import("./ha-selector-navigation"),
number: () => import("./ha-selector-number"),
numeric_threshold: () => import("./ha-selector-numeric-threshold"),
object: () => import("./ha-selector-object"),
qr_code: () => import("./ha-selector-qr-code"),
select: () => import("./ha-selector-select"),
@@ -61,6 +62,7 @@ const LOAD_ELEMENTS = {
location: () => import("./ha-selector-location"),
color_temp: () => import("./ha-selector-color-temp"),
ui_action: () => import("./ha-selector-ui-action"),
ui_clock_date_format: () => import("./ha-selector-ui-clock-date-format"),
ui_color: () => import("./ha-selector-ui-color"),
ui_state_content: () => import("./ha-selector-ui-state-content"),
};

View File

@@ -14,6 +14,8 @@ export class HaSettingsRow extends LitElement {
@property({ type: Boolean, attribute: "wrap-heading", reflect: true })
public wrapHeading = false;
@property({ type: Boolean, reflect: true }) public empty = false;
protected render(): TemplateResult {
return html`
<div class="prefix-wrap">
@@ -27,25 +29,30 @@ export class HaSettingsRow extends LitElement {
<div class="secondary"><slot name="description"></slot></div>
</div>
</div>
<div class="content"><slot></slot></div>
<div class="content">
<slot></slot>
</div>
`;
}
static styles = css`
:host {
display: flex;
padding: 0 16px;
padding: 0 var(--ha-space-4);
align-content: normal;
align-self: auto;
align-items: center;
}
.body {
padding-top: 8px;
padding-bottom: 8px;
padding-top: var(--settings-row-body-padding-top, var(--ha-space-2));
padding-bottom: var(
--settings-row-body-padding-bottom,
var(--ha-space-2)
);
padding-left: 0;
padding-inline-start: 0;
padding-right: 16px;
padding-inline-end: 16px;
padding-right: var(--ha-space-4);
padding-inline-end: var(--ha-space-4);
overflow: hidden;
display: var(--layout-vertical_-_display, flex);
flex-direction: var(--layout-vertical_-_flex-direction, column);
@@ -63,7 +70,7 @@ export class HaSettingsRow extends LitElement {
}
.body > .secondary {
display: block;
padding-top: 4px;
padding-top: var(--ha-space-1);
font-family: var(
--mdc-typography-body2-font-family,
var(--mdc-typography-font-family, var(--ha-font-family-body))
@@ -90,7 +97,10 @@ export class HaSettingsRow extends LitElement {
justify-content: flex-end;
flex: 1;
min-width: 0;
padding: 16px 0;
padding: var(--settings-row-content-padding-block, var(--ha-space-4)) 0;
}
:host([empty]) .content {
display: none;
}
.content ::slotted(*) {
width: var(--settings-row-content-width);
@@ -99,16 +109,16 @@ export class HaSettingsRow extends LitElement {
align-items: normal;
flex-direction: column;
border-top: 1px solid var(--divider-color);
padding-bottom: 8px;
padding-bottom: var(--ha-space-2);
}
::slotted(ha-switch) {
padding: 16px 0;
padding: var(--settings-row-switch-padding-block, var(--ha-space-4)) 0;
}
.secondary {
white-space: normal;
}
.prefix-wrap {
flex: 1;
flex: var(--settings-row-prefix-flex, 1);
display: var(--settings-row-prefix-display);
}
:host([narrow]) .prefix-wrap {

View File

@@ -1,7 +1,7 @@
import { consume, type ContextType } from "@lit/context";
import { mdiContentCopy, mdiEye, mdiEyeOff } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { consume, type ContextType } from "@lit/context";
import { copyToClipboard } from "../../common/util/copy-clipboard";
import { localizeContext } from "../../data/context";
import { showToast } from "../../util/toast";

View File

@@ -1,22 +1,21 @@
import { consume, type ContextType } from "@lit/context";
import { mdiDeleteOutline, mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../common/dom/fire_event";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-button";
import "./ha-icon-button";
import "./ha-input-helper-text";
import "./ha-sortable";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
@customElement("ha-multi-textfield")
class HaMultiTextField extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
import { fireEvent } from "../../common/dom/fire_event";
import { localizeContext } from "../../data/context";
import { haStyle } from "../../resources/styles";
import "../ha-button";
import "../ha-icon-button";
import "../ha-input-helper-text";
import "../ha-sortable";
import "./ha-input";
import type { HaInput, InputType } from "./ha-input";
@customElement("ha-input-multi")
class HaInputMulti extends LitElement {
@property({ attribute: false }) public value?: string[];
@property({ type: Boolean }) public disabled = false;
@@ -25,7 +24,7 @@ class HaMultiTextField extends LitElement {
@property({ attribute: false }) public helper?: string;
@property({ attribute: false }) public inputType?: string;
@property({ attribute: false }) public inputType?: InputType;
@property({ attribute: false }) public inputSuffix?: string;
@@ -47,6 +46,10 @@ class HaMultiTextField extends LitElement {
@property({ type: Boolean, attribute: "update-on-blur" })
public updateOnBlur = false;
@state()
@consume({ context: localizeContext, subscribe: true })
private localize?: ContextType<typeof localizeContext>;
protected render() {
return html`
<ha-sortable
@@ -63,9 +66,7 @@ class HaMultiTextField extends LitElement {
const indexSuffix = `${this.itemIndex ? ` ${index + 1}` : ""}`;
return html`
<div class="layout horizontal center-center row">
<ha-textfield
.suffix=${this.inputSuffix}
.prefix=${this.inputPrefix}
<ha-input
.type=${this.inputType}
.autocomplete=${this.autocomplete}
.disabled=${this.disabled}
@@ -78,13 +79,20 @@ class HaMultiTextField extends LitElement {
@input=${this._editItem}
@change=${this._editItem}
@keydown=${this._keyDown}
></ha-textfield>
>
${this.inputPrefix
? html`<span slot="start">${this.inputPrefix}</span>`
: nothing}
${this.inputSuffix
? html`<span slot="end">${this.inputSuffix}</span>`
: nothing}
</ha-input>
<ha-icon-button
.disabled=${this.disabled}
.index=${index}
slot="navigationIcon"
.label=${this.removeLabel ??
this.hass?.localize("ui.common.remove") ??
this.localize?.("ui.common.remove") ??
"Remove"}
@click=${this._removeItem}
.path=${mdiDeleteOutline}
@@ -112,10 +120,10 @@ class HaMultiTextField extends LitElement {
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.addLabel ??
(this.label
? this.hass?.localize("ui.components.multi-textfield.add_item", {
? this.localize?.("ui.components.multi-textfield.add_item", {
item: this.label,
})
: this.hass?.localize("ui.common.add")) ??
: this.localize?.("ui.common.add")) ??
"Add"}
</ha-button>
</div>
@@ -138,8 +146,8 @@ class HaMultiTextField extends LitElement {
const items = [...this._items, ""];
this._fireChanged(items);
await this.updateComplete;
const field = this.shadowRoot?.querySelector(`ha-textfield[data-last]`) as
| HaTextField
const field = this.shadowRoot?.querySelector(`ha-input[data-last]`) as
| HaInput
| undefined;
field?.focus();
}
@@ -191,9 +199,7 @@ class HaMultiTextField extends LitElement {
css`
.row {
margin-bottom: 8px;
}
ha-textfield {
display: block;
--ha-input-padding-bottom: 0;
}
ha-icon-button {
display: block;
@@ -210,6 +216,6 @@ class HaMultiTextField extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"ha-multi-textfield": HaMultiTextField;
"ha-input-multi": HaInputMulti;
}
}

View File

@@ -14,7 +14,7 @@ export interface LovelaceBaseSectionConfig {
disabled?: boolean;
column_span?: number;
row_span?: number;
background?: LovelaceSectionBackgroundConfig;
background?: boolean | LovelaceSectionBackgroundConfig;
/**
* @deprecated Use heading card instead.
*/
@@ -34,6 +34,15 @@ export type LovelaceSectionRawConfig =
| LovelaceSectionConfig
| LovelaceStrategySectionConfig;
export function resolveSectionBackground(
background: boolean | LovelaceSectionBackgroundConfig | undefined
): LovelaceSectionBackgroundConfig | undefined {
if (typeof background === "boolean") {
return background ? {} : undefined;
}
return background;
}
export function isStrategySection(
section: LovelaceSectionRawConfig
): section is LovelaceStrategySectionConfig {

View File

@@ -56,6 +56,7 @@ export type Selector =
| MediaSelector
| NavigationSelector
| NumberSelector
| NumericThresholdSelector
| ObjectSelector
| AssistPipelineSelector
| QRCodeSelector
@@ -74,6 +75,7 @@ export type Selector =
| TTSSelector
| TTSVoiceSelector
| UiActionSelector
| UiClockDateFormatSelector
| UiColorSelector
| UiStateContentSelector
| BackupLocationSelector;
@@ -240,8 +242,8 @@ interface EntitySelectorFilter {
integration?: string;
domain?: string | readonly string[];
device_class?: string | readonly string[];
unit_of_measurement?: string | readonly string[];
supported_features?: number | [number];
unit_of_measurement?: string | readonly string[];
}
export interface EntitySelector {
@@ -363,6 +365,14 @@ export interface NumberSelector {
} | null;
}
export interface NumericThresholdSelector {
numeric_threshold: {
unit_of_measurement?: readonly string[];
number?: NumberSelector["number"];
entity?: EntitySelectorFilter | readonly EntitySelectorFilter[];
} | null;
}
interface ObjectSelectorField {
selector: Selector;
label?: string;
@@ -507,6 +517,10 @@ export interface UiActionSelector {
} | null;
}
export interface UiClockDateFormatSelector {
ui_clock_date_format: {} | null;
}
export interface UiColorExtraOption {
value: string;
label: string;

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

@@ -7,6 +7,22 @@ import { CONTINUOUS_DOMAINS } from "../../data/logbook";
import type { HomeAssistant } from "../../types";
import { isNumericEntity } from "../../data/history";
export const MORE_INFO_VIEWS = [
"info",
"history",
"settings",
"related",
"add_to",
"details",
] as const;
export type MoreInfoView = (typeof MORE_INFO_VIEWS)[number];
export const isMoreInfoView = (
value: string | undefined
): value is MoreInfoView =>
value !== undefined && (MORE_INFO_VIEWS as readonly string[]).includes(value);
export const DOMAINS_NO_INFO = ["camera", "configurator"];
/**
* Entity domains that should be editable *if* they have an id present;

View File

@@ -22,7 +22,7 @@ import { customElement, property, query, state } from "lit/decorators";
import { cache } from "lit/directives/cache";
import { classMap } from "lit/directives/class-map";
import { keyed } from "lit/directives/keyed";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { computeAreaName } from "../../common/entity/compute_area_name";
@@ -72,6 +72,7 @@ import {
DOMAINS_WITH_MORE_INFO,
EDITABLE_DOMAINS_WITH_ID,
EDITABLE_DOMAINS_WITH_UNIQUE_ID,
type MoreInfoView,
computeShowHistoryComponent,
computeShowLogBookComponent,
} from "./const";
@@ -79,6 +80,7 @@ import "./controls/more-info-default";
import type { FavoritesDialogContext } from "./favorites";
import { getFavoritesDialogHandler } from "./favorites";
import "./ha-more-info-add-to";
import "./ha-more-info-details";
import "./ha-more-info-history-and-logbook";
import "./ha-more-info-info";
import "./ha-more-info-settings";
@@ -86,16 +88,14 @@ import "./more-info-content";
export interface MoreInfoDialogParams {
entityId: string | null;
view?: View;
view?: MoreInfoView;
/** @deprecated Use `view` instead */
tab?: View;
tab?: MoreInfoView;
large?: boolean;
data?: Record<string, any>;
parentElement?: LitElement;
}
type View = "info" | "history" | "settings" | "related" | "add_to";
interface ChildView {
viewTag: string;
viewTitle?: string;
@@ -112,7 +112,7 @@ declare global {
}
}
const DEFAULT_VIEW: View = "info";
const DEFAULT_VIEW: MoreInfoView = "info";
@customElement("ha-more-info-dialog")
export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
@@ -134,9 +134,9 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
@state() private _data?: Record<string, any>;
@state() private _currView: View = DEFAULT_VIEW;
@state() private _currView: MoreInfoView = DEFAULT_VIEW;
@state() private _initialView: View = DEFAULT_VIEW;
@state() private _initialView: MoreInfoView = DEFAULT_VIEW;
@state() private _childView?: ChildView;
@@ -163,10 +163,15 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
return;
}
const view = params.view || params.tab || DEFAULT_VIEW;
this._data = params.data;
this._currView = params.view || DEFAULT_VIEW;
this._initialView = params.view || DEFAULT_VIEW;
this._currView = view;
this._initialView = view;
this._childView = undefined;
this._infoEditMode = false;
this._detailsYamlMode = false;
this.large = params.large ?? false;
this._fill = false;
this._open = true;
@@ -253,7 +258,7 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
return entity?.device_id ?? null;
}
private _setView(view: View) {
private _setView(view: MoreInfoView) {
history.replaceState(
{
...history.state,
@@ -278,6 +283,13 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
this._detailsYamlMode = false;
return;
}
if (
this._initialView !== DEFAULT_VIEW &&
this._currView === this._initialView
) {
this._resetInitialView();
return;
}
if (this._initialView !== this._currView) {
this._setView(this._initialView);
return;
@@ -404,7 +416,9 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
this._resetInitialView();
break;
case "details":
this._showDetails();
this._setView("details");
break;
default:
break;
}
}
@@ -449,15 +463,6 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
this._entry = result.entity_entry;
}
private _showDetails(): void {
import("./ha-more-info-details");
this._childView = {
viewTag: "ha-more-info-details",
viewTitle: this.hass.localize("ui.dialogs.more_info_control.details"),
viewParams: { entityId: this._entityId },
};
}
private async _copyFavorites() {
const favoritesContext = this._getFavoritesContext();
@@ -509,13 +514,8 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
(deviceId && this.hass.devices[deviceId].entry_type) || "device";
const isDefaultView = this._currView === DEFAULT_VIEW && !this._childView;
const isSpecificInitialView =
this._initialView !== DEFAULT_VIEW && !this._childView;
const showCloseIcon =
(isDefaultView &&
this._parentEntityIds.length === 0 &&
!this._childView) ||
(isSpecificInitialView && !this._childView);
isDefaultView && this._parentEntityIds.length === 0 && !this._childView;
const context = stateObj
? getEntityContext(
@@ -549,7 +549,11 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
const breadcrumb = [areaName, deviceName, entityName].filter(
(v): v is string => Boolean(v)
);
const title = this._childView?.viewTitle || breadcrumb.pop() || entityId;
const defaultTitle = breadcrumb.pop() || entityId;
const title =
this._currView === "details"
? this.hass.localize("ui.dialogs.more_info_control.details")
: this._childView?.viewTitle || defaultTitle;
const favoritesContext =
this._entry && stateObj
@@ -774,26 +778,16 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
`
: nothing}
`
: isSpecificInitialView
: this._currView === "details"
? html`
<ha-dropdown
<ha-icon-button
slot="headerActionItems"
@closed=${stopPropagation}
@wa-select=${this._handleMenuAction}
placement="bottom-end"
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item value="info">
<ha-svg-icon slot="icon" .path=${mdiInformationOutline}>
</ha-svg-icon>
${this.hass.localize("ui.dialogs.more_info_control.info")}
</ha-dropdown-item>
</ha-dropdown>
.label=${this.hass.localize(
"ui.dialogs.more_info_control.toggle_yaml_mode"
)}
.path=${mdiCodeBraces}
@click=${this._toggleDetailsYamlMode}
></ha-icon-button>
`
: this._childView?.viewTag === "ha-more-info-details"
? html`
@@ -828,12 +822,24 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
this._childView
? html`
<div class="child-view">
${dynamicElement(this._childView.viewTag, {
hass: this.hass,
entry: this._entry,
params: this._childView.viewParams,
yamlMode: this._detailsYamlMode,
})}
${this._childView.viewTag ===
"ha-more-info-view-voice-assistants"
? html`
<ha-more-info-view-voice-assistants
.hass=${this.hass}
.entry=${this._entry!}
.params=${this._childView.viewParams}
></ha-more-info-view-voice-assistants>
`
: this._childView.viewTag ===
"ha-more-info-view-vacuum-segment-mapping"
? html`
<ha-more-info-view-vacuum-segment-mapping
.hass=${this.hass}
.params=${this._childView.viewParams}
></ha-more-info-view-vacuum-segment-mapping>
`
: nothing}
</div>
`
: this._currView === "info"
@@ -879,7 +885,16 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
@add-to-action-selected=${this._goBack}
></ha-more-info-add-to>
`
: nothing
: this._currView === "details"
? html`
<ha-more-info-details
.hass=${this.hass}
.entry=${this._entry}
.params=${{ entityId }}
.yamlMode=${this._detailsYamlMode}
></ha-more-info-details>
`
: nothing
)}
</div>
`
@@ -898,14 +913,11 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
const previousChildView = changedProps.get("_childView") as
| ChildView
const previousView = changedProps.get("_currView") as
| MoreInfoView
| undefined;
if (
previousChildView?.viewTag === "ha-more-info-details" &&
this._childView?.viewTag !== "ha-more-info-details"
) {
if (previousView === "details" && this._currView !== "details") {
const dialog =
this._dialogElement?.shadowRoot?.querySelector("ha-dialog");
if (dialog) {
@@ -914,7 +926,6 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
}
if (changedProps.has("_currView")) {
this._childView = undefined;
this._infoEditMode = false;
this._detailsYamlMode = false;
}
@@ -935,15 +946,25 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
window.addEventListener("show-dialog", this._disableEscapeKeyClose);
}
private _handleMoreInfoEvent(ev) {
private _handleMoreInfoEvent(ev: HASSDomEvent<MoreInfoDialogParams>) {
ev.stopPropagation();
const entityId = ev.detail.entityId;
if (!entityId) {
return;
}
const view = ev.detail.view || ev.detail.tab || DEFAULT_VIEW;
if (entityId === this._entityId) {
this._infoEditMode = false;
this._detailsYamlMode = false;
this._setView(view);
return;
}
this._parentEntityIds = [...this._parentEntityIds, this._entityId!];
this._entityId = entityId;
this._currView = DEFAULT_VIEW;
this._currView = view === "details" ? view : DEFAULT_VIEW;
this._initialView = view;
this._infoEditMode = false;
this._detailsYamlMode = false;
this._childView = undefined;
this._loadEntityRegistryEntry();
}
@@ -998,12 +1019,6 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
);
}
.child-view {
display: flex;
flex-direction: column;
flex: 1;
}
ha-more-info-history-and-logbook {
padding: var(--ha-space-2) var(--ha-space-6) var(--ha-space-6)
var(--ha-space-6);

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

@@ -16,7 +16,7 @@ class SupervisorAppMetric extends LitElement {
protected render(): TemplateResult {
const roundedValue = roundWithOneDecimal(this.value);
return html`<ha-settings-row>
return html`<ha-settings-row empty>
<span slot="heading"> ${this.description} </span>
<div slot="description" .title=${this.tooltip ?? ""}>
<span class="value"> ${roundedValue} % </span>
@@ -60,9 +60,9 @@ class SupervisorAppMetric extends LitElement {
}
.value {
width: 48px;
padding-right: 4px;
padding-right: var(--ha-space-1);
padding-inline-start: initial;
padding-inline-end: 4px;
padding-inline-end: var(--ha-space-1);
flex-shrink: 0;
}
`;

View File

@@ -629,7 +629,7 @@ class SupervisorAppInfo extends LitElement {
</div>
<div>
${this.addon.version && this.addon.state === "started"
? html`<ha-settings-row ?three-line=${this.narrow}>
? html`<ha-settings-row ?three-line=${this.narrow} empty>
<span slot="heading">
${this.hass.localize(
"ui.panel.config.apps.dashboard.hostname"
@@ -1286,7 +1286,7 @@ class SupervisorAppInfo extends LitElement {
}
ha-card {
display: block;
margin-bottom: 16px;
margin-bottom: var(--ha-space-4);
}
ha-card.warning {
background-color: var(--error-color);
@@ -1322,21 +1322,18 @@ class SupervisorAppInfo extends LitElement {
}
.errors {
color: var(--error-color);
margin-bottom: 16px;
margin-bottom: var(--ha-space-4);
}
.description {
margin-bottom: 16px;
margin-bottom: var(--ha-space-4);
}
img.logo {
max-width: 100%;
max-height: 60px;
margin: 16px 0;
margin: var(--ha-space-4) 0;
display: block;
}
ha-switch {
display: flex;
}
ha-svg-icon.running {
color: var(--success-color);
}
@@ -1383,7 +1380,7 @@ class SupervisorAppInfo extends LitElement {
);
}
.capabilities {
margin-bottom: 16px;
margin-bottom: var(--ha-space-4);
}
.card-actions {
justify-content: space-between;
@@ -1399,13 +1396,17 @@ class SupervisorAppInfo extends LitElement {
cursor: pointer;
}
ha-markdown {
padding: 16px;
padding: var(--ha-space-4);
--markdown-image-background-color: transparent;
--markdown-image-border-radius: 0;
--markdown-image-min-height: auto;
--markdown-image-text-indent: 0;
--markdown-image-transition: none;
}
ha-settings-row,
supervisor-app-metric {
--settings-row-prefix-flex: 2;
}
ha-settings-row {
padding: 0;
height: 54px;
@@ -1418,6 +1419,14 @@ class SupervisorAppInfo extends LitElement {
ha-settings-row[three-line] {
height: 74px;
}
.addon-options ha-settings-row {
padding: 0;
width: 100%;
--settings-row-body-padding-top: 0;
--settings-row-body-padding-bottom: 0;
--settings-row-content-padding-block: var(--ha-space-2);
--settings-row-switch-padding-block: var(--ha-space-2);
}
.addon-options {
max-width: 90%;
@@ -1439,7 +1448,7 @@ class SupervisorAppInfo extends LitElement {
:host > ha-alert {
display: block;
margin-bottom: 16px;
margin-bottom: var(--ha-space-4);
}
a {
@@ -1447,7 +1456,7 @@ class SupervisorAppInfo extends LitElement {
}
supervisor-app-update-available-card {
padding-bottom: 16px;
padding-bottom: var(--ha-space-4);
}
@media (max-width: 720px) {

View File

@@ -1,7 +1,8 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { formatListWithAnds } from "../../../common/string/format-list";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import "../../../components/ha-button";
@@ -16,6 +17,7 @@ import {
removeEntityRegistryEntry,
updateEntityRegistryEntry,
} from "../../../data/entity/entity_registry";
import { findRelated } from "../../../data/search";
import { fetchIntegrationManifest } from "../../../data/integration";
import {
showAlertDialog,
@@ -27,6 +29,9 @@ import type { HomeAssistant } from "../../../types";
import { showDeviceRegistryDetailDialog } from "../devices/device-registry-detail/show-dialog-device-registry-detail";
import "./entity-registry-settings-editor";
import type { EntityRegistrySettingsEditor } from "./entity-registry-settings-editor";
import { computeEntityEntryName } from "../../../common/entity/compute_entity_name";
const RELATED_ENTITY_DOMAINS = ["automation", "script", "group", "scene"];
@customElement("entity-registry-settings")
export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
@@ -209,11 +214,14 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
}
private async _confirmDeleteEntry(): Promise<void> {
const confirmationText = await this._getDeleteConfirmationText();
if (
!(await showConfirmationDialog(this, {
text: this.hass.localize(
"ui.dialogs.entity_registry.editor.confirm_delete"
title: this.hass.localize(
"ui.dialogs.entity_registry.editor.confirm_delete_title"
),
text: confirmationText,
confirmText: this.hass.localize("ui.common.delete"),
dismissText: this.hass.localize("ui.common.cancel"),
destructive: true,
@@ -236,6 +244,46 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
}
}
private async _getDeleteConfirmationText(): Promise<string | TemplateResult> {
const mainText = this.hass.localize(
"ui.dialogs.entity_registry.editor.confirm_delete",
{ entity_name: computeEntityEntryName(this.entry) }
);
try {
const related = await findRelated(
this.hass,
"entity",
this.entry.entity_id
);
const relatedItems = RELATED_ENTITY_DOMAINS.map((domain) => {
const count = related[domain]?.length || 0;
if (count === 0) {
return undefined;
}
return this.hass.localize(
`ui.dialogs.entity_registry.editor.confirm_delete_count.${domain}`,
{ count }
);
}).filter((item): item is string => Boolean(item));
if (relatedItems.length === 0) {
return mainText;
}
return html`${mainText} <br /><br />
${this.hass.localize(
"ui.dialogs.entity_registry.editor.confirm_delete_related",
{
items: formatListWithAnds(this.hass.locale, relatedItems),
}
)}`;
} catch (_err) {
return mainText;
}
}
static get styles(): CSSResultGroup {
return [
haStyle,

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

@@ -0,0 +1,221 @@
import type { ClockCardConfig, ClockCardDatePart } from "../types";
type ClockCardSeparatorPart = Extract<
ClockCardDatePart,
"separator-dash" | "separator-slash" | "separator-dot" | "separator-new-line"
>;
type ClockCardValuePart = Exclude<ClockCardDatePart, ClockCardSeparatorPart>;
/**
* Normalized date configuration used by clock card renderers.
*/
export interface ClockCardDateConfig {
parts: ClockCardDatePart[];
}
/**
* All selectable date tokens exposed by the clock card editor.
*/
export const CLOCK_CARD_DATE_PARTS: readonly ClockCardDatePart[] = [
"weekday-short",
"weekday-long",
"day-numeric",
"day-2-digit",
"month-short",
"month-long",
"month-numeric",
"month-2-digit",
"year-2-digit",
"year-numeric",
"separator-dash",
"separator-slash",
"separator-dot",
"separator-new-line",
];
const DATE_PART_OPTIONS: Record<
ClockCardValuePart,
Pick<Intl.DateTimeFormatOptions, "weekday" | "day" | "month" | "year">
> = {
"weekday-short": { weekday: "short" },
"weekday-long": { weekday: "long" },
"day-numeric": { day: "numeric" },
"day-2-digit": { day: "2-digit" },
"month-short": { month: "short" },
"month-long": { month: "long" },
"month-numeric": { month: "numeric" },
"month-2-digit": { month: "2-digit" },
"year-2-digit": { year: "2-digit" },
"year-numeric": { year: "numeric" },
};
const DATE_SEPARATORS: Record<ClockCardSeparatorPart, string> = {
"separator-dash": "-",
"separator-slash": "/",
"separator-dot": ".",
"separator-new-line": "\n",
};
const DATE_SEPARATOR_PARTS = new Set<ClockCardSeparatorPart>([
"separator-dash",
"separator-slash",
"separator-dot",
"separator-new-line",
]);
const DATE_PART_FORMATTERS = new Map<string, Intl.DateTimeFormat>();
const isClockCardDatePart = (value: string): value is ClockCardDatePart =>
CLOCK_CARD_DATE_PARTS.includes(value as ClockCardDatePart);
const isDateSeparatorPart = (
part: ClockCardDatePart
): part is ClockCardSeparatorPart =>
DATE_SEPARATOR_PARTS.has(part as ClockCardSeparatorPart);
/**
* Returns a reusable formatter for a specific date token.
*/
const getDatePartFormatter = (
part: ClockCardValuePart,
language: string,
timeZone?: string
): Intl.DateTimeFormat => {
const cacheKey = `${language}|${timeZone || ""}|${part}`;
const cached = DATE_PART_FORMATTERS.get(cacheKey);
if (cached) {
return cached;
}
const formatter = new Intl.DateTimeFormat(language, {
...DATE_PART_OPTIONS[part],
...(timeZone ? { timeZone } : {}),
});
DATE_PART_FORMATTERS.set(cacheKey, formatter);
return formatter;
};
const formatDatePart = (
part: ClockCardValuePart,
date: Date,
language: string,
timeZone?: string
) => getDatePartFormatter(part, language, timeZone).format(date);
/**
* Applies a single date token to Intl.DateTimeFormat options.
*/
const applyDatePartOption = (
options: Intl.DateTimeFormatOptions,
part: ClockCardDatePart
) => {
if (isDateSeparatorPart(part)) {
return;
}
const partOptions = DATE_PART_OPTIONS[part];
if (partOptions.weekday) {
options.weekday = partOptions.weekday;
}
if (partOptions.day) {
options.day = partOptions.day;
}
if (partOptions.month) {
options.month = partOptions.month;
}
if (partOptions.year) {
options.year = partOptions.year;
}
};
/**
* Sanitizes configured date tokens while preserving their literal order.
*/
const normalizeDateParts = (
parts: ClockCardConfig["date_format"]
): ClockCardDatePart[] =>
parts?.filter((part): part is ClockCardDatePart =>
isClockCardDatePart(part)
) || [];
/**
* Returns a normalized date config from a card configuration object.
*/
export const getClockCardDateConfig = (
config?: Pick<ClockCardConfig, "date_format">
): ClockCardDateConfig => ({
parts: normalizeDateParts(config?.date_format),
});
/**
* Checks whether the clock configuration resolves to any visible date output.
*/
export const hasClockCardDate = (
config?: Pick<ClockCardConfig, "date_format">
): boolean => getClockCardDateConfig(config).parts.length > 0;
/**
* Converts normalized date tokens into Intl.DateTimeFormat options.
*
* Separator tokens are ignored. If multiple tokens target the same Intl field,
* the last one wins.
*/
export const getClockCardDateTimeFormatOptions = (
dateConfig: ClockCardDateConfig
): Intl.DateTimeFormatOptions => {
const options: Intl.DateTimeFormatOptions = {};
dateConfig.parts.forEach((part) => {
applyDatePartOption(options, part);
});
return options;
};
/**
* Builds the final date string from literal date tokens.
*
* Value tokens are localized through Intl.DateTimeFormat. Separator tokens are
* always rendered literally. A default space is only inserted between adjacent
* value tokens.
*/
export const formatClockCardDate = (
date: Date,
dateConfig: ClockCardDateConfig,
language: string,
timeZone?: string
): string => {
let result = "";
let previousRenderedPartWasValue = false;
dateConfig.parts.forEach((part) => {
if (isDateSeparatorPart(part)) {
result += DATE_SEPARATORS[part];
previousRenderedPartWasValue = false;
return;
}
const value = formatDatePart(part, date, language, timeZone);
if (!value) {
return;
}
if (previousRenderedPartWasValue) {
result += " ";
}
result += value;
previousRenderedPartWasValue = true;
});
return result;
};

View File

@@ -2,9 +2,16 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
import type { HomeAssistant } from "../../../../types";
import type { ClockCardConfig } from "../types";
import {
formatClockCardDate,
getClockCardDateConfig,
hasClockCardDate,
} from "./clock-date-format";
function romanize12HourClock(num: number) {
const numerals = [
@@ -26,6 +33,11 @@ function romanize12HourClock(num: number) {
return numerals[num];
}
const DATE_UPDATE_INTERVAL = 60_000;
const QUARTER_TICKS = Array.from({ length: 4 }, (_, i) => i);
const HOUR_TICKS = Array.from({ length: 12 }, (_, i) => i);
const MINUTE_TICKS = Array.from({ length: 60 }, (_, i) => i);
@customElement("hui-clock-card-analog")
export class HuiClockCardAnalog extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@@ -40,42 +52,18 @@ export class HuiClockCardAnalog extends LitElement {
@state() private _secondOffsetSec?: number;
private _initDate() {
if (!this.config || !this.hass) {
return;
}
@state() private _date?: string;
let locale = this.hass.locale;
if (this.config.time_format) {
locale = { ...locale, time_format: this.config.time_format };
}
private _dateInterval?: number;
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h12",
timeZone:
this.config.time_zone ||
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone),
});
private _timeZone?: string;
this._computeOffsets();
}
protected updated(changedProps: PropertyValues) {
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || oldHass.locale !== this.hass?.locale) {
this._initDate();
}
}
}
private _language?: string;
public connectedCallback() {
super.connectedCallback();
document.addEventListener("visibilitychange", this._handleVisibilityChange);
this._computeOffsets();
this._initDate();
}
public disconnectedCallback() {
@@ -84,18 +72,87 @@ export class HuiClockCardAnalog extends LitElement {
"visibilitychange",
this._handleVisibilityChange
);
this._stopDateTick();
}
protected updated(changedProps: PropertyValues) {
if (changedProps.has("config") || changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (
changedProps.has("config") ||
!oldHass ||
oldHass.locale !== this.hass?.locale
) {
this._initDate();
}
}
}
private _handleVisibilityChange = () => {
if (!document.hidden) {
this._computeOffsets();
this._updateDate();
}
};
private _initDate() {
if (!this.config || !this.hass) {
this._stopDateTick();
this._date = undefined;
return;
}
let locale = this.hass.locale;
if (this.config.time_format) {
locale = { ...locale, time_format: this.config.time_format };
}
const timeZone =
this.config.time_zone ||
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone);
this._language = this.hass.locale.language;
this._timeZone = timeZone;
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h12",
timeZone,
});
this._computeOffsets();
this._updateDate();
if (this.isConnected && hasClockCardDate(this.config)) {
this._startDateTick();
} else {
this._stopDateTick();
}
}
private _startDateTick() {
this._stopDateTick();
this._dateInterval = window.setInterval(
() => this._updateDate(),
DATE_UPDATE_INTERVAL
);
}
private _stopDateTick() {
if (this._dateInterval) {
clearInterval(this._dateInterval);
this._dateInterval = undefined;
}
}
private _computeOffsets() {
if (!this._dateTimeFormat) return;
const parts = this._dateTimeFormat.formatToParts();
const date = new Date();
const parts = this._dateTimeFormat.formatToParts(date);
const hourStr = parts.find((p) => p.type === "hour")?.value;
const minuteStr = parts.find((p) => p.type === "minute")?.value;
const secondStr = parts.find((p) => p.type === "second")?.value;
@@ -103,7 +160,7 @@ export class HuiClockCardAnalog extends LitElement {
const hour = hourStr ? parseInt(hourStr, 10) : 0;
const minute = minuteStr ? parseInt(minuteStr, 10) : 0;
const second = secondStr ? parseInt(secondStr, 10) : 0;
const ms = new Date().getMilliseconds();
const ms = date.getMilliseconds();
const secondsWithMs = second + ms / 1000;
const hour12 = hour % 12;
@@ -113,16 +170,44 @@ export class HuiClockCardAnalog extends LitElement {
this._hourOffsetSec = hour12 * 3600 + minute * 60 + secondsWithMs;
}
private _updateDate() {
if (!this.config || !hasClockCardDate(this.config) || !this._language) {
this._date = undefined;
return;
}
const dateConfig = getClockCardDateConfig(this.config);
this._date = formatClockCardDate(
new Date(),
dateConfig,
this._language,
this._timeZone
);
}
private _computeClock = memoizeOne((config: ClockCardConfig) => {
const faceParts = config.face_style?.split("_");
const dateConfig = getClockCardDateConfig(config);
const showDate = hasClockCardDate(config);
const isLongDate =
dateConfig.parts.includes("month-long") ||
dateConfig.parts.includes("weekday-long");
return {
sizeClass: config.clock_size ? `size-${config.clock_size}` : "",
isNumbers: faceParts?.includes("numbers") ?? false,
isRoman: faceParts?.includes("roman") ?? false,
isUpright: faceParts?.includes("upright") ?? false,
showDate,
isLongDate,
};
});
render() {
if (!this.config) return nothing;
const sizeClass = this.config.clock_size
? `size-${this.config.clock_size}`
: "";
const isNumbers = this.config?.face_style?.startsWith("numbers");
const isRoman = this.config?.face_style?.startsWith("roman");
const isUpright = this.config?.face_style?.endsWith("upright");
const { sizeClass, isNumbers, isRoman, isUpright, isLongDate, showDate } =
this._computeClock(this.config);
const indicator = (number?: number) => html`
<div
@@ -163,14 +248,14 @@ export class HuiClockCardAnalog extends LitElement {
})}
>
${this.config.ticks === "quarter"
? Array.from({ length: 4 }, (_, i) => i).map(
? QUARTER_TICKS.map(
(i) =>
// 4 ticks (12, 3, 6, 9) at 0°, 90°, 180°, 270°
html`
<div
aria-hidden="true"
class="tick hour"
style=${`--tick-rotation: ${i * 90}deg;`}
style=${styleMap({ "--tick-rotation": `${i * 90}deg` })}
>
${indicator([12, 3, 6, 9][i])}
</div>
@@ -178,28 +263,30 @@ export class HuiClockCardAnalog extends LitElement {
)
: !this.config.ticks || // Default to hour ticks
this.config.ticks === "hour"
? Array.from({ length: 12 }, (_, i) => i).map(
? HOUR_TICKS.map(
(i) =>
// 12 ticks (1-12)
html`
<div
aria-hidden="true"
class="tick hour"
style=${`--tick-rotation: ${i * 30}deg;`}
style=${styleMap({ "--tick-rotation": `${i * 30}deg` })}
>
${indicator(((i + 11) % 12) + 1)}
</div>
`
)
: this.config.ticks === "minute"
? Array.from({ length: 60 }, (_, i) => i).map(
? MINUTE_TICKS.map(
(i) =>
// 60 ticks (1-60)
html`
<div
aria-hidden="true"
class="tick ${i % 5 === 0 ? "hour" : "minute"}"
style=${`--tick-rotation: ${i * 6}deg;`}
style=${styleMap({
"--tick-rotation": `${i * 6}deg`,
})}
>
${i % 5 === 0
? indicator(((i / 5 + 11) % 12) + 1)
@@ -208,14 +295,34 @@ export class HuiClockCardAnalog extends LitElement {
`
)
: nothing}
${showDate
? html`<div
class=${classMap({
date: true,
[sizeClass]: true,
"long-date": isLongDate,
})}
>
${this._date
?.split("\n")
.map((line, index) =>
index > 0 ? html`<br />${line}` : line
)}
</div>`
: nothing}
<div class="center-dot"></div>
<div
class="hand hour"
style=${`animation-delay: -${this._hourOffsetSec ?? 0}s;`}
style=${styleMap({
"animation-delay": `-${this._hourOffsetSec ?? 0}s`,
})}
></div>
<div
class="hand minute"
style=${`animation-delay: -${this._minuteOffsetSec ?? 0}s;`}
style=${styleMap({
"animation-delay": `-${this._minuteOffsetSec ?? 0}s`,
})}
></div>
${this.config.show_seconds
? html`<div
@@ -224,11 +331,13 @@ export class HuiClockCardAnalog extends LitElement {
second: true,
step: this.config.seconds_motion === "tick",
})}
style=${`animation-delay: -${
(this.config.seconds_motion === "tick"
? Math.floor(this._secondOffsetSec ?? 0)
: (this._secondOffsetSec ?? 0)) as number
}s;`}
style=${styleMap({
"animation-delay": `-${
(this.config.seconds_motion === "tick"
? Math.floor(this._secondOffsetSec ?? 0)
: (this._secondOffsetSec ?? 0)) as number
}s`,
})}
></div>`
: nothing}
</div>
@@ -407,6 +516,36 @@ export class HuiClockCardAnalog extends LitElement {
transform: translate(-50%, 0) rotate(360deg);
}
}
.date {
position: absolute;
top: 68%;
left: 50%;
transform: translate(-50%, -50%);
display: block;
color: var(--primary-text-color);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
text-align: center;
opacity: 0.8;
max-width: 87%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.date.long-date:not(.size-medium):not(.size-large) {
font-size: var(--ha-font-size-xs);
}
.date.size-medium {
font-size: var(--ha-font-size-l);
}
.date.size-large {
font-size: var(--ha-font-size-xl);
}
`;
}

View File

@@ -5,6 +5,11 @@ import type { ClockCardConfig } from "../types";
import type { HomeAssistant } from "../../../../types";
import { useAmPm } from "../../../../common/datetime/use_am_pm";
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
import {
formatClockCardDate,
getClockCardDateConfig,
hasClockCardDate,
} from "./clock-date-format";
const INTERVAL = 1000;
@@ -24,10 +29,20 @@ export class HuiClockCardDigital extends LitElement {
@state() private _timeAmPm?: string;
@state() private _date?: string;
private _tickInterval?: undefined | number;
private _lastDateMinute?: string;
private _timeZone?: string;
private _language?: string;
private _initDate() {
if (!this.config || !this.hass) {
this._date = undefined;
this._lastDateMinute = undefined;
return;
}
@@ -37,24 +52,35 @@ export class HuiClockCardDigital extends LitElement {
locale = { ...locale, time_format: this.config.time_format };
}
const timeZone =
this.config?.time_zone ||
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone);
const h12 = useAmPm(locale);
this._language = this.hass.locale.language;
this._timeZone = timeZone;
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
hour: h12 ? "numeric" : "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: h12 ? "h12" : "h23",
timeZone:
this.config?.time_zone ||
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone),
timeZone,
});
this._lastDateMinute = undefined;
this._tick();
}
protected updated(changedProps: PropertyValues) {
if (changedProps.has("hass")) {
if (changedProps.has("config") || changedProps.has("hass")) {
const oldHass = changedProps.get("hass");
if (!oldHass || oldHass.locale !== this.hass?.locale) {
if (
changedProps.has("config") ||
!oldHass ||
oldHass.locale !== this.hass?.locale
) {
this._initDate();
}
}
@@ -71,6 +97,7 @@ export class HuiClockCardDigital extends LitElement {
}
private _startTick() {
this._stopTick();
this._tickInterval = window.setInterval(() => this._tick(), INTERVAL);
this._tick();
}
@@ -85,7 +112,8 @@ export class HuiClockCardDigital extends LitElement {
private _tick() {
if (!this._dateTimeFormat) return;
const parts = this._dateTimeFormat.formatToParts();
const date = new Date();
const parts = this._dateTimeFormat.formatToParts(date);
this._timeHour = parts.find((part) => part.type === "hour")?.value;
this._timeMinute = parts.find((part) => part.type === "minute")?.value;
@@ -93,6 +121,33 @@ export class HuiClockCardDigital extends LitElement {
? parts.find((part) => part.type === "second")?.value
: undefined;
this._timeAmPm = parts.find((part) => part.type === "dayPeriod")?.value;
this._updateDate(date);
}
private _updateDate(date: Date) {
if (!this.config || !hasClockCardDate(this.config) || !this._language) {
this._date = undefined;
this._lastDateMinute = undefined;
return;
}
if (
this._timeMinute !== undefined &&
this._timeMinute === this._lastDateMinute &&
this._date !== undefined
) {
return;
}
const dateConfig = getClockCardDateConfig(this.config);
this._date = formatClockCardDate(
date,
dateConfig,
this._language,
this._timeZone
);
this._lastDateMinute = this._timeMinute;
}
render() {
@@ -101,18 +156,30 @@ export class HuiClockCardDigital extends LitElement {
const sizeClass = this.config.clock_size
? `size-${this.config.clock_size}`
: "";
const showDate = hasClockCardDate(this.config);
return html`
<div class="time-parts ${sizeClass}">
<div class="time-part hour">${this._timeHour}</div>
<div class="time-part minute">${this._timeMinute}</div>
${this._timeSecond !== undefined
? html`<div class="time-part second">${this._timeSecond}</div>`
: nothing}
${this._timeAmPm !== undefined
? html`<div class="time-part am-pm">${this._timeAmPm}</div>`
: nothing}
<div class="clock-container">
<div class="time-parts ${sizeClass}">
<div class="time-part hour">${this._timeHour}</div>
<div class="time-part minute">${this._timeMinute}</div>
${this._timeSecond !== undefined
? html`<div class="time-part second">${this._timeSecond}</div>`
: nothing}
${this._timeAmPm !== undefined
? html`<div class="time-part am-pm">${this._timeAmPm}</div>`
: nothing}
</div>
</div>
${showDate
? html`<div class="date-container">
<div class="date ${sizeClass}">
${this._date
?.split("\n")
.map((line, index) => (index > 0 ? html`<br />${line}` : line))}
</div>
</div>`
: nothing}
`;
}
@@ -121,6 +188,17 @@ export class HuiClockCardDigital extends LitElement {
display: block;
}
.clock-container {
width: 100%;
display: flex;
justify-content: center;
}
.date-container {
width: 100%;
margin-top: var(--ha-space-1);
}
.time-parts {
align-items: center;
display: grid;
@@ -188,6 +266,21 @@ export class HuiClockCardDigital extends LitElement {
content: ":";
margin: 0 2px;
}
.date {
text-align: center;
opacity: 0.8;
font-size: var(--ha-font-size-s);
line-height: 1.1;
}
.date.size-medium {
font-size: var(--ha-font-size-l);
}
.date.size-large {
font-size: var(--ha-font-size-2xl);
}
`;
}

View File

@@ -167,7 +167,7 @@ class HuiGaugeCard extends LitElement implements LovelaceCard {
.needle=${this._config!.needle}
.levels=${this._config!.needle ? this._severityLevels() : undefined}
></ha-gauge>
<div class="name" .title=${name}>${name}</div>
<p class="title" .title=${name}>${name}</p>
</ha-card>
`;
}
@@ -282,10 +282,15 @@ class HuiGaugeCard extends LitElement implements LovelaceCard {
}
static styles = css`
:host {
position: relative;
display: block;
height: 100%;
}
ha-card {
height: 100%;
overflow: hidden;
padding: 16px;
padding: var(--ha-space-3);
display: flex;
align-items: center;
justify-content: center;
@@ -301,16 +306,23 @@ class HuiGaugeCard extends LitElement implements LovelaceCard {
outline: none;
}
ha-gauge {
.title {
width: 100%;
font-size: var(--ha-font-size-l);
line-height: var(--ha-line-height-expanded);
padding: 0px 0px var(--ha-space-2) 0px;
margin: 0;
text-align: center;
box-sizing: border-box;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: none;
color: var(--primary-text-color);
}
.name {
text-align: center;
line-height: initial;
color: var(--primary-text-color);
ha-gauge {
width: 100%;
font-size: var(--ha-font-size-m);
}
`;
}

View File

@@ -441,12 +441,29 @@ export interface ClockCardConfig extends LovelaceCardConfig {
time_format?: TimeFormat;
time_zone?: string;
no_background?: boolean;
date_format?: ClockCardDatePart[];
// Analog clock options
border?: boolean;
ticks?: "none" | "quarter" | "hour" | "minute";
face_style?: "markers" | "numbers_upright" | "roman";
}
export type ClockCardDatePart =
| "weekday-short"
| "weekday-long"
| "day-numeric"
| "day-2-digit"
| "month-short"
| "month-long"
| "month-numeric"
| "month-2-digit"
| "year-2-digit"
| "year-numeric"
| "separator-dash"
| "separator-slash"
| "separator-dot"
| "separator-new-line";
export interface MediaControlCardConfig extends LovelaceCardConfig {
entity: string;
name?: string | EntityNameItem | EntityNameItem[];

View File

@@ -3,16 +3,15 @@ import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
array,
assert,
assign,
boolean,
defaulted,
enums,
literal,
object,
optional,
string,
union,
} from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form";
@@ -20,57 +19,46 @@ import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type { ClockCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { TimeFormat } from "../../../../data/translation";
import {
CLOCK_CARD_DATE_PARTS,
getClockCardDateConfig,
} from "../../cards/clock/clock-date-format";
const cardConfigStruct = assign(
baseLovelaceCardConfig,
object({
title: optional(string()),
clock_style: optional(union([literal("digital"), literal("analog")])),
clock_size: optional(
union([literal("small"), literal("medium"), literal("large")])
),
clock_style: optional(enums(["digital", "analog"])),
clock_size: optional(enums(["small", "medium", "large"])),
time_format: optional(enums(Object.values(TimeFormat))),
time_zone: optional(enums(Object.keys(timezones))),
show_seconds: optional(boolean()),
no_background: optional(boolean()),
date_format: optional(defaulted(array(enums(CLOCK_CARD_DATE_PARTS)), [])),
// Analog clock options
border: optional(defaulted(boolean(), false)),
ticks: optional(
defaulted(
union([
literal("none"),
literal("quarter"),
literal("hour"),
literal("minute"),
]),
literal("hour")
)
defaulted(enums(["none", "quarter", "hour", "minute"]), "hour")
),
seconds_motion: optional(
defaulted(
union([literal("continuous"), literal("tick")]),
literal("continuous")
)
defaulted(enums(["continuous", "tick"]), "continuous")
),
face_style: optional(
defaulted(
union([
literal("markers"),
literal("numbers_upright"),
literal("roman"),
]),
literal("markers")
)
defaulted(enums(["markers", "numbers_upright", "roman"]), "markers")
),
})
);
type ClockCardFormData = Omit<ClockCardConfig, "time_format"> & {
time_format?: ClockCardConfig["time_format"] | "auto";
};
@customElement("hui-clock-card-editor")
export class HuiClockCardEditor
extends LitElement
@@ -93,7 +81,7 @@ export class HuiClockCardEditor
name: "clock_style",
selector: {
select: {
mode: "dropdown",
mode: "box",
options: ["digital", "analog"].map((value) => ({
value,
label: localize(
@@ -119,6 +107,13 @@ export class HuiClockCardEditor
},
{ name: "show_seconds", selector: { boolean: {} } },
{ name: "no_background", selector: { boolean: {} } },
{
name: "date_format",
required: false,
selector: {
ui_clock_date_format: {},
},
},
...(clockStyle === "digital"
? ([
{
@@ -241,18 +236,28 @@ export class HuiClockCardEditor
] as const satisfies readonly HaFormSchema[]
);
private _data = memoizeOne((config) => ({
clock_style: "digital",
clock_size: "small",
time_format: "auto",
show_seconds: false,
no_background: false,
// Analog clock options
border: false,
ticks: "hour",
face_style: "markers",
...config,
}));
private _data = memoizeOne((config: ClockCardConfig): ClockCardFormData => {
const dateConfig = getClockCardDateConfig(config);
const data: ClockCardFormData = {
...config,
clock_style: config.clock_style ?? "digital",
clock_size: config.clock_size ?? "small",
time_format: config.time_format ?? "auto",
show_seconds: config.show_seconds ?? false,
no_background: config.no_background ?? false,
// Analog clock options
border: config.border ?? false,
ticks: config.ticks ?? "hour",
face_style: config.face_style ?? "markers",
};
if (config.date_format === undefined) {
data.date_format = dateConfig.parts;
}
return data;
});
public setConfig(config: ClockCardConfig): void {
assert(config, cardConfigStruct);
@@ -270,8 +275,9 @@ export class HuiClockCardEditor
.data=${this._data(this._config)}
.schema=${this._schema(
this.hass.localize,
this._data(this._config).clock_style,
this._data(this._config).ticks,
this._data(this._config)
.clock_style as ClockCardConfig["clock_style"],
this._data(this._config).ticks as ClockCardConfig["ticks"],
this._data(this._config).show_seconds
)}
.computeLabel=${this._computeLabelCallback}
@@ -281,35 +287,40 @@ export class HuiClockCardEditor
`;
}
private _valueChanged(ev: CustomEvent): void {
if (ev.detail.value.time_format === "auto") {
delete ev.detail.value.time_format;
private _valueChanged(ev: ValueChangedEvent<ClockCardFormData>): void {
const config = ev.detail.value;
if (!config.date_format || config.date_format.length === 0) {
delete config.date_format;
}
if (ev.detail.value.clock_style === "analog") {
ev.detail.value.border = ev.detail.value.border ?? false;
ev.detail.value.ticks = ev.detail.value.ticks ?? "hour";
ev.detail.value.face_style = ev.detail.value.face_style ?? "markers";
if (ev.detail.value.show_seconds) {
ev.detail.value.seconds_motion =
ev.detail.value.seconds_motion ?? "continuous";
if (config.time_format === "auto") {
delete config.time_format;
}
if (config.clock_style === "analog") {
config.border = config.border ?? false;
config.ticks = config.ticks ?? "hour";
config.face_style = config.face_style ?? "markers";
if (config.show_seconds) {
config.seconds_motion = config.seconds_motion ?? "continuous";
} else {
delete ev.detail.value.seconds_motion;
delete config.seconds_motion;
}
} else {
delete ev.detail.value.border;
delete ev.detail.value.ticks;
delete ev.detail.value.face_style;
delete ev.detail.value.seconds_motion;
delete config.border;
delete config.ticks;
delete config.face_style;
delete config.seconds_motion;
}
if (ev.detail.value.ticks !== "none") {
ev.detail.value.face_style = ev.detail.value.face_style ?? "markers";
if (config.ticks !== "none") {
config.face_style = config.face_style ?? "markers";
} else {
delete ev.detail.value.face_style;
delete config.face_style;
}
fireEvent(this, "config-changed", { config: ev.detail.value });
fireEvent(this, "config-changed", { config });
}
private _computeLabelCallback = (
@@ -344,6 +355,10 @@ export class HuiClockCardEditor
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.no_background`
);
case "date_format":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.date.label`
);
case "border":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.border.label`
@@ -369,6 +384,10 @@ export class HuiClockCardEditor
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "date_format":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.date.description`
);
case "border":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.border.description`

View File

@@ -3,17 +3,18 @@ import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-form/ha-form";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import "../../../../components/ha-form/ha-form";
import {
DEFAULT_SECTION_BACKGROUND_OPACITY,
resolveSectionBackground,
type LovelaceSectionRawConfig,
} from "../../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type { HomeAssistant } from "../../../../types";
interface SettingsData {
@@ -95,13 +96,14 @@ export class HuiDialogEditSection extends LitElement {
render() {
const backgroundEnabled = this.config.background !== undefined;
const background = resolveSectionBackground(this.config.background);
const data: SettingsData = {
column_span: this.config.column_span || 1,
background_enabled: backgroundEnabled,
background_color: this.config.background?.color ?? "default",
background_color: background?.color ?? "default",
background_opacity:
this.config.background?.opacity ?? DEFAULT_SECTION_BACKGROUND_OPACITY,
background?.opacity ?? DEFAULT_SECTION_BACKGROUND_OPACITY,
};
const schema = this._schema(
@@ -146,12 +148,13 @@ export class HuiDialogEditSection extends LitElement {
};
if (newData.background_enabled) {
const hasCustomColor =
newData.background_color !== undefined &&
newData.background_color !== "default";
newConfig.background = {
...(newData.background_color && newData.background_color !== "default"
? { color: newData.background_color }
: {}),
opacity:
newData.background_opacity ?? DEFAULT_SECTION_BACKGROUND_OPACITY,
...(hasCustomColor ? { color: newData.background_color } : {}),
opacity: newData.background_opacity!,
};
} else {
delete newConfig.background;

View File

@@ -70,6 +70,7 @@ import {
showAlertDialog,
showConfirmationDialog,
} from "../../dialogs/generic/show-dialog-box";
import { isMoreInfoView } from "../../dialogs/more-info/const";
import { showMoreInfoDialog } from "../../dialogs/more-info/show-ha-more-info-dialog";
import { showQuickBar } from "../../dialogs/quick-bar/show-dialog-quick-bar";
import { showVoiceCommandDialog } from "../../dialogs/voice-command-dialog/show-ha-voice-command-dialog";
@@ -716,11 +717,18 @@ class HUIRoot extends LitElement {
this._showVoiceCommandDialog();
} else if (searchParams["more-info-entity-id"]) {
const entityId = searchParams["more-info-entity-id"];
const view = searchParams["more-info-view"];
this._clearParam("more-info-entity-id");
if (view) {
this._clearParam("more-info-view");
}
// Wait for the next render to ensure the view is fully loaded
// because the more info dialog is closed when the url changes
afterNextRender(() => {
this._showMoreInfoDialog(entityId);
showMoreInfoDialog(this, {
entityId,
view: isMoreInfoView(view) ? view : undefined,
});
});
}
}
@@ -975,10 +983,6 @@ class HUIRoot extends LitElement {
showVoiceCommandDialog(this, this.hass, { pipeline_id: "last_used" });
};
private _showMoreInfoDialog(entityId: string): void {
showMoreInfoDialog(this, { entityId });
}
private _enableEditMode = async () => {
if (this._yamlMode) {
showAlertDialog(this, {

View File

@@ -1,16 +1,17 @@
import { css, LitElement, nothing } from "lit";
import { css, LitElement, nothing, unsafeCSS } from "lit";
import type { PropertyValues } from "lit";
import { customElement, property } from "lit/decorators";
import { computeCssColor } from "../../../common/color/compute-color";
import {
DEFAULT_SECTION_BACKGROUND_OPACITY,
resolveSectionBackground,
type LovelaceSectionBackgroundConfig,
} from "../../../data/lovelace/config/section";
@customElement("hui-section-background")
export class HuiSectionBackground extends LitElement {
@property({ attribute: false })
public background?: LovelaceSectionBackgroundConfig;
public background?: boolean | LovelaceSectionBackgroundConfig;
protected render() {
return nothing;
@@ -18,14 +19,15 @@ export class HuiSectionBackground extends LitElement {
protected willUpdate(changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties);
if (changedProperties.has("background") && this.background) {
const color = this.background.color
? computeCssColor(this.background.color)
: "var(--ha-section-background-color, var(--secondary-background-color))";
this.style.setProperty("--section-background", color);
const opacity =
this.background.opacity ?? DEFAULT_SECTION_BACKGROUND_OPACITY;
this.style.setProperty("--section-background-opacity", `${opacity}%`);
if (changedProperties.has("background")) {
const resolved = resolveSectionBackground(this.background);
if (resolved) {
const color = resolved.color ? computeCssColor(resolved.color) : null;
this.style.setProperty("--section-background-color", color);
const opacity =
resolved.opacity !== undefined ? `${resolved.opacity}%` : null;
this.style.setProperty("--section-background-opacity", opacity);
}
}
}
@@ -34,8 +36,14 @@ export class HuiSectionBackground extends LitElement {
position: absolute;
inset: 0;
border-radius: inherit;
background-color: var(--section-background, none);
opacity: var(--section-background-opacity, 100%);
background-color: var(
--section-background-color,
var(--ha-section-background-color, var(--secondary-background-color))
);
opacity: var(
--section-background-opacity,
${unsafeCSS(DEFAULT_SECTION_BACKGROUND_OPACITY)}%
);
z-index: 0;
pointer-events: none;
}

View File

@@ -30,11 +30,11 @@ import {
import type { HuiSection } from "../sections/hui-section";
import "../sections/hui-section-background";
import type { Lovelace } from "../types";
import { computeSectionsBackgroundAlignment } from "./sections-background-alignment";
import { generateDefaultSection } from "./default-section";
import "./hui-view-footer";
import "./hui-view-header";
import "./hui-view-sidebar";
import { computeSectionsBackgroundAlignment } from "./sections-background-alignment";
export const DEFAULT_MAX_COLUMNS = 4;

View File

@@ -554,6 +554,18 @@
"text": {
"show_password": "Show password",
"hide_password": "Hide password"
},
"numeric_threshold": {
"type": "Threshold type",
"above": "Above",
"below": "Below",
"in_range": "In range",
"outside_range": "Outside range",
"unit": "Unit",
"number": "Number",
"entity": "Entity",
"from": "From",
"to": "To"
}
},
"logbook": {
@@ -1825,7 +1837,15 @@
"enabled_restart_confirm": "Restart Home Assistant to finish enabling the entities",
"hidden_explanation": "Hidden entities will not be included in auto-populated dashboards or when their area, device or label is referenced. Their history is still tracked and you can still interact with them with actions.",
"delete": "Delete",
"confirm_delete": "Are you sure you want to delete this entity?",
"confirm_delete_title": "Delete entity",
"confirm_delete": "Deleting \"{entity_name}\" is an irreversible action. Are you sure you want to continue?",
"confirm_delete_related": "This entity is part of {items}. If you delete it, you will need to update those manually.",
"confirm_delete_count": {
"automation": "{count} {count, plural,\n one {automation}\n other {automations}\n}",
"script": "{count} {count, plural,\n one {script}\n other {scripts}\n}",
"group": "{count} {count, plural,\n one {group}\n other {groups}\n}",
"scene": "{count} {count, plural,\n one {scene}\n other {scenes}\n}"
},
"update": "Update",
"note": "Note: This might not work yet with all integrations.",
"use_device_area": "Use device area",
@@ -9346,6 +9366,33 @@
"large": "Large"
},
"show_seconds": "Display seconds",
"date": {
"label": "Date",
"description": "Select and order the date parts. Add a separator to control punctuation.",
"sections": {
"weekday": "Weekday",
"day": "Day",
"month": "Month",
"year": "Year",
"separator": "Separator"
},
"parts": {
"weekday-short": "Short",
"weekday-long": "Long",
"day-numeric": "Numeric",
"day-2-digit": "2-digit",
"month-short": "Short",
"month-long": "Long",
"month-numeric": "Numeric",
"month-2-digit": "2-digit",
"year-2-digit": "2-digit",
"year-numeric": "Full",
"separator-dash": "Dash",
"separator-slash": "Slash",
"separator-dot": "Dot",
"separator-new-line": "New line"
}
},
"time_format": "[%key:ui::panel::profile::time_format::dropdown_label%]",
"time_formats": {
"auto": "Use user settings",
@@ -10653,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

@@ -0,0 +1,261 @@
import { assert, describe, it } from "vitest";
import type { ClockCardDatePart } from "../../../../src/panels/lovelace/cards/types";
import {
formatClockCardDate,
getClockCardDateConfig,
getClockCardDateTimeFormatOptions,
hasClockCardDate,
} from "../../../../src/panels/lovelace/cards/clock/clock-date-format";
describe("clock-date-format", () => {
const date = new Date("2024-11-08T10:20:30.000Z");
it("returns an empty config when date format is missing", () => {
assert.deepEqual(getClockCardDateConfig(), { parts: [] });
});
it("preserves literal token order", () => {
const config = getClockCardDateConfig({
date_format: [
"day-numeric",
"separator-dot",
"month-short",
"month-long",
"separator-slash",
"year-2-digit",
"year-numeric",
],
});
assert.deepEqual(config.parts, [
"day-numeric",
"separator-dot",
"month-short",
"month-long",
"separator-slash",
"year-2-digit",
"year-numeric",
]);
});
it("filters invalid date tokens", () => {
const config = getClockCardDateConfig({
date_format: [
"month-short",
"invalid",
"year-2-digit",
] as unknown as ClockCardDatePart[],
});
assert.deepEqual(config.parts, ["month-short", "year-2-digit"]);
});
it("builds Intl options from selected date tokens", () => {
const options = getClockCardDateTimeFormatOptions({
parts: [
"weekday-short",
"separator-slash",
"day-2-digit",
"month-long",
"month-numeric",
"year-2-digit",
],
});
assert.deepEqual(options, {
weekday: "short",
day: "2-digit",
month: "numeric",
year: "2-digit",
});
});
it("reports whether any date part is configured", () => {
assert.equal(hasClockCardDate(), false);
assert.equal(hasClockCardDate({ date_format: [] }), false);
assert.equal(hasClockCardDate({ date_format: ["separator-dot"] }), true);
assert.equal(
hasClockCardDate({ date_format: ["separator-new-line"] }),
true
);
assert.equal(hasClockCardDate({ date_format: ["weekday-short"] }), true);
});
it("formats output in configured part order with literal separators", () => {
const result = formatClockCardDate(
date,
{
parts: [
"month-numeric",
"separator-slash",
"day-2-digit",
"separator-dash",
"year-2-digit",
],
},
"en",
"UTC"
);
assert.equal(result, "11/08-24");
});
it("uses separator only for the next gap", () => {
const result = formatClockCardDate(
date,
{
parts: [
"day-numeric",
"separator-dot",
"month-numeric",
"year-numeric",
],
},
"en",
"UTC"
);
assert.equal(result, "8.11 2024");
});
it("supports using the same separator style multiple times", () => {
const result = formatClockCardDate(
date,
{
parts: [
"month-numeric",
"separator-slash",
"day-2-digit",
"separator-slash",
"year-2-digit",
],
},
"en",
"UTC"
);
assert.equal(result, "11/08/24");
});
it("renders separators even when no value follows", () => {
const result = formatClockCardDate(
date,
{
parts: ["day-numeric", "separator-dash", "separator-dot"],
},
"en",
"UTC"
);
assert.equal(result, "8-.");
});
it("renders separators even when no value precedes", () => {
const result = formatClockCardDate(
date,
{
parts: ["separator-slash", "separator-dot", "day-numeric"],
},
"en",
"UTC"
);
assert.equal(result, "/.8");
});
it("renders all consecutive separators between values", () => {
const result = formatClockCardDate(
date,
{
parts: [
"day-numeric",
"separator-dash",
"separator-slash",
"separator-dot",
"month-numeric",
],
},
"en",
"UTC"
);
assert.equal(result, "8-/.11");
});
it("renders repeated separators without deduplication", () => {
const result = formatClockCardDate(
date,
{
parts: [
"day-numeric",
"separator-dash",
"separator-dash",
"separator-dash",
"month-numeric",
],
},
"en",
"UTC"
);
assert.equal(result, "8---11");
});
it("renders separator-only configurations", () => {
const result = formatClockCardDate(
date,
{
parts: ["separator-dash", "separator-slash", "separator-dot"],
},
"en",
"UTC"
);
assert.equal(result, "-/.");
});
it("supports inserting a new line between date values", () => {
const result = formatClockCardDate(
date,
{
parts: [
"month-numeric",
"separator-new-line",
"day-2-digit",
"year-numeric",
],
},
"en",
"UTC"
);
assert.equal(result, "11\n08 2024");
});
it("allows multiple variants for the same date part", () => {
const result = formatClockCardDate(
date,
{
parts: ["month-short", "month-long", "year-numeric"],
},
"en",
"UTC"
);
assert.equal(result, "Nov November 2024");
});
it("filters invalid tokens when formatting", () => {
const config = getClockCardDateConfig({
date_format: [
"month-numeric",
"invalid",
"year-numeric",
] as unknown as ClockCardDatePart[],
});
const result = formatClockCardDate(date, config, "en", "UTC");
assert.equal(result, "11 2024");
});
});

View File

@@ -4,7 +4,7 @@ import { computeSectionsBackgroundAlignment } from "../../../../src/panels/lovel
function mockSection(
opts: {
background?: {};
background?: boolean;
column_span?: number;
hidden?: boolean;
} = {}
@@ -20,7 +20,7 @@ function mockSection(
describe("computeSectionsBackgroundAlignment", () => {
it("returns empty set for single column layout", () => {
const sections = [mockSection(), mockSection({ background: {} })];
const sections = [mockSection(), mockSection({ background: true })];
const result = computeSectionsBackgroundAlignment(sections, 1);
expect(result.size).toBe(0);
});
@@ -33,15 +33,15 @@ describe("computeSectionsBackgroundAlignment", () => {
it("returns empty set when all sections have background", () => {
const sections = [
mockSection({ background: {} }),
mockSection({ background: {} }),
mockSection({ background: true }),
mockSection({ background: true }),
];
const result = computeSectionsBackgroundAlignment(sections, 2);
expect(result.size).toBe(0);
});
it("marks section without background on same row as one with background", () => {
const sections = [mockSection(), mockSection({ background: {} })];
const sections = [mockSection(), mockSection({ background: true })];
const result = computeSectionsBackgroundAlignment(sections, 2);
expect(result.has(0)).toBe(true);
expect(result.has(1)).toBe(false);
@@ -49,7 +49,7 @@ describe("computeSectionsBackgroundAlignment", () => {
it("does not mark sections on different rows", () => {
// Row 1: section 0 (no bg), Row 2: section 1 (bg)
const sections = [mockSection(), mockSection({ background: {} })];
const sections = [mockSection(), mockSection({ background: true })];
const result = computeSectionsBackgroundAlignment(sections, 1);
expect(result.size).toBe(0);
});
@@ -59,7 +59,7 @@ describe("computeSectionsBackgroundAlignment", () => {
// section 2 (span 1, no bg) = row 2
const sections = [
mockSection({ column_span: 2 }),
mockSection({ column_span: 2, background: {} }),
mockSection({ column_span: 2, background: true }),
mockSection(),
];
const result = computeSectionsBackgroundAlignment(sections, 4);
@@ -71,7 +71,7 @@ describe("computeSectionsBackgroundAlignment", () => {
it("wraps to new row when column_span exceeds remaining space", () => {
// 2 columns: section 0 (span 1, bg) on row 1, section 1 (span 2, no bg) on row 2
const sections = [
mockSection({ background: {} }),
mockSection({ background: true }),
mockSection({ column_span: 2 }),
];
const result = computeSectionsBackgroundAlignment(sections, 2);
@@ -81,7 +81,7 @@ describe("computeSectionsBackgroundAlignment", () => {
it("skips hidden sections", () => {
// section 0 (hidden, bg) should not cause section 1 to need margin
const sections = [
mockSection({ hidden: true, background: {} }),
mockSection({ hidden: true, background: true }),
mockSection(),
];
const result = computeSectionsBackgroundAlignment(sections, 2);
@@ -94,7 +94,7 @@ describe("computeSectionsBackgroundAlignment", () => {
// Row 2: section 2 (no bg) + section 3 (no bg) -> no margin needed
const sections = [
mockSection(),
mockSection({ background: {} }),
mockSection({ background: true }),
mockSection(),
mockSection(),
];
@@ -114,7 +114,7 @@ describe("computeSectionsBackgroundAlignment", () => {
// section with span 10 in a 2-column layout should be treated as span 2
// Row 1: section 0 (span clamped to 2, bg), Row 2: section 1 (no bg)
const sections = [
mockSection({ column_span: 10, background: {} }),
mockSection({ column_span: 10, background: true }),
mockSection(),
];
const result = computeSectionsBackgroundAlignment(sections, 2);
@@ -125,7 +125,7 @@ describe("computeSectionsBackgroundAlignment", () => {
// 3 columns: section 0 (no bg) + section 1 (bg) + section 2 (no bg)
const sections = [
mockSection(),
mockSection({ background: {} }),
mockSection({ background: true }),
mockSection(),
];
const result = computeSectionsBackgroundAlignment(sections, 3);

177
yarn.lock
View File

@@ -1533,14 +1533,21 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/ecma402-abstract@npm:3.1.2":
version: 3.1.2
resolution: "@formatjs/ecma402-abstract@npm:3.1.2"
"@formatjs/bigdecimal@npm:0.2.0":
version: 0.2.0
resolution: "@formatjs/bigdecimal@npm:0.2.0"
checksum: 10/bf036a4414537e4292c8585a6b308b8e8fbb6efc876e6221124eb0d3b6c1d3cba4d4b9b9bf7dca0b52095e00af4c98fb147f563abffd743f5f9fbfd6655550ec
languageName: node
linkType: hard
"@formatjs/ecma402-abstract@npm:3.2.0":
version: 3.2.0
resolution: "@formatjs/ecma402-abstract@npm:3.2.0"
dependencies:
"@formatjs/bigdecimal": "npm:0.2.0"
"@formatjs/fast-memoize": "npm:3.1.1"
"@formatjs/intl-localematcher": "npm:0.8.2"
decimal.js: "npm:^10.6.0"
checksum: 10/0005707da31ac43dfd394659489055bf98a1a5719e838e2812b7efaeb2aebefd9e108e7e9f42c4dfc3a2912af61851f271d3eacfbfd56ee89214bc7bf1988e75
checksum: 10/f28647ce8d44207e685dc619425e05aded48c24374c055b8a693adaf471bd9c09ef3f2b33cc8f9ffc875748b30fcc80486272edcb7b0ebaff6e82c3417563384
languageName: node
linkType: hard
@@ -1551,53 +1558,53 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/icu-messageformat-parser@npm:3.5.2":
version: 3.5.2
resolution: "@formatjs/icu-messageformat-parser@npm:3.5.2"
"@formatjs/icu-messageformat-parser@npm:3.5.3":
version: 3.5.3
resolution: "@formatjs/icu-messageformat-parser@npm:3.5.3"
dependencies:
"@formatjs/ecma402-abstract": "npm:3.1.2"
"@formatjs/icu-skeleton-parser": "npm:2.1.2"
checksum: 10/4d47d9793bc4d51583c15b61e5d2e540b8e9a4cda1f5b2c8b292738432ef0b59249ece6fa2d899c040469e85dd45da77fcaeed2d796ae8d7c2d06dadd418de52
"@formatjs/ecma402-abstract": "npm:3.2.0"
"@formatjs/icu-skeleton-parser": "npm:2.1.3"
checksum: 10/a57118cd8cce392078d806bc6a4818006fc4d38c4a4eb18a2424a392094068128472ad2814bf6d670316f8d6adbbefea639fecdefcac67576cd19d9b7f56ece3
languageName: node
linkType: hard
"@formatjs/icu-skeleton-parser@npm:2.1.2":
version: 2.1.2
resolution: "@formatjs/icu-skeleton-parser@npm:2.1.2"
"@formatjs/icu-skeleton-parser@npm:2.1.3":
version: 2.1.3
resolution: "@formatjs/icu-skeleton-parser@npm:2.1.3"
dependencies:
"@formatjs/ecma402-abstract": "npm:3.1.2"
checksum: 10/a35dbe27ff87c691aa8ee32ed5474561923fe921e09d8d4ea959e85026bbb5c546227358d607cc8c30ce928574fd8c5f0e05772b43e5590324917e0e9bcd8ecb
"@formatjs/ecma402-abstract": "npm:3.2.0"
checksum: 10/9b0513d07b52a9c8783fe29a7f7beb6ec5a15ca1133c7186addd46eadc096eac2afec87e8d7c9714705f4f1498963807bbea4872295710f30136170ca5ea883e
languageName: node
linkType: hard
"@formatjs/intl-datetimeformat@npm:7.2.6":
version: 7.2.6
resolution: "@formatjs/intl-datetimeformat@npm:7.2.6"
"@formatjs/intl-datetimeformat@npm:7.3.1":
version: 7.3.1
resolution: "@formatjs/intl-datetimeformat@npm:7.3.1"
dependencies:
"@formatjs/ecma402-abstract": "npm:3.1.2"
"@formatjs/bigdecimal": "npm:0.2.0"
"@formatjs/ecma402-abstract": "npm:3.2.0"
"@formatjs/intl-localematcher": "npm:0.8.2"
decimal.js: "npm:^10.6.0"
checksum: 10/df824f0e663f904a6ebd0e6e70faa8853c99219dc70a56c8da86aed346ef603c6325062ec5038a8ece781c7267ad9268267bc6930aecdfece7b715f26e8a7a5a
checksum: 10/06a2775fc1a6a60defedbd3e9165286624e6a7b0e1c41b4f0a3771a32cac22e62e28a6a3a8185c7879b5553569b48359411061eb0342ca59810c7c0b7249e578
languageName: node
linkType: hard
"@formatjs/intl-displaynames@npm:7.2.3":
version: 7.2.3
resolution: "@formatjs/intl-displaynames@npm:7.2.3"
"@formatjs/intl-displaynames@npm:7.3.1":
version: 7.3.1
resolution: "@formatjs/intl-displaynames@npm:7.3.1"
dependencies:
"@formatjs/ecma402-abstract": "npm:3.1.2"
"@formatjs/ecma402-abstract": "npm:3.2.0"
"@formatjs/intl-localematcher": "npm:0.8.2"
checksum: 10/37e07754738b3abee6e3a3cc028c0c586932112dd9f6971fff622e133fde6020c0065042b0ad7d0c750d30e3e76e6f7d4e1ff41be8367325ca2cddb8198a3665
checksum: 10/026f49c428cea693c8018668f7f867e03d881a52f1a6237dea6c1aff3361f06e7691555f421e2b0bd5bc718d5235f7d8fdefe31a9ec9a77b4d534aed9d206653
languageName: node
linkType: hard
"@formatjs/intl-durationformat@npm:0.10.2":
version: 0.10.2
resolution: "@formatjs/intl-durationformat@npm:0.10.2"
"@formatjs/intl-durationformat@npm:0.10.3":
version: 0.10.3
resolution: "@formatjs/intl-durationformat@npm:0.10.3"
dependencies:
"@formatjs/ecma402-abstract": "npm:3.1.2"
"@formatjs/ecma402-abstract": "npm:3.2.0"
"@formatjs/intl-localematcher": "npm:0.8.2"
checksum: 10/27aced459ed503f92ca3db54bb6f42967a1c8f085d5208a570b1c4acb31424f23f3c634bb35c562ac3a84f08c64cdbf7d4c9a44ba937bbb6a68ece7b084104be
checksum: 10/33c945164ee8b6cebcff61e080592a707d364dab2191d755b992d109db3038d5acb39d577160de4ba51d97ad2868243ea2e18f96b7dc52f5222d933edaba5f7f
languageName: node
linkType: hard
@@ -1608,24 +1615,24 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/intl-listformat@npm:8.2.3":
version: 8.2.3
resolution: "@formatjs/intl-listformat@npm:8.2.3"
"@formatjs/intl-listformat@npm:8.3.1":
version: 8.3.1
resolution: "@formatjs/intl-listformat@npm:8.3.1"
dependencies:
"@formatjs/ecma402-abstract": "npm:3.1.2"
"@formatjs/ecma402-abstract": "npm:3.2.0"
"@formatjs/intl-localematcher": "npm:0.8.2"
checksum: 10/1408a58632fef7c0817531657ecb345a997163ad955d96fa4c5b541a0b018e82e578c92ad53e316decd926027141174a9ed42ac406196235fddd83c2bdb0fc58
checksum: 10/62d20411e8c96a8682a4eda336f9c8fdea858f6c181acd8034007b7a65a273b07efcdb3fb0c9a876ef5cd587f35bb1ddf18d36fc6615da8647d24f972bb260af
languageName: node
linkType: hard
"@formatjs/intl-locale@npm:5.2.2":
version: 5.2.2
resolution: "@formatjs/intl-locale@npm:5.2.2"
"@formatjs/intl-locale@npm:5.3.1":
version: 5.3.1
resolution: "@formatjs/intl-locale@npm:5.3.1"
dependencies:
"@formatjs/ecma402-abstract": "npm:3.1.2"
"@formatjs/ecma402-abstract": "npm:3.2.0"
"@formatjs/intl-getcanonicallocales": "npm:3.2.2"
"@formatjs/intl-supportedvaluesof": "npm:2.2.2"
checksum: 10/f50228e5b2fc5808a200a236d992ca816f1a6b3c08f903b0bd20abc19d7b90e312719b1783de7f57a1e5fb99cac7b4f31e5e601b664964b16c65f673051033db
"@formatjs/intl-supportedvaluesof": "npm:2.3.0"
checksum: 10/48baf6a7c1df6b94989dc096638fdd02f204b656e44dd6917c5ecefbdd78f02394eeed53b6266ae1983ba6231efb9aa0fd138596d568abd8b026b49abb9c0c12
languageName: node
linkType: hard
@@ -1638,45 +1645,45 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/intl-numberformat@npm:9.2.4":
version: 9.2.4
resolution: "@formatjs/intl-numberformat@npm:9.2.4"
"@formatjs/intl-numberformat@npm:9.3.1":
version: 9.3.1
resolution: "@formatjs/intl-numberformat@npm:9.3.1"
dependencies:
"@formatjs/ecma402-abstract": "npm:3.1.2"
"@formatjs/bigdecimal": "npm:0.2.0"
"@formatjs/ecma402-abstract": "npm:3.2.0"
"@formatjs/intl-localematcher": "npm:0.8.2"
decimal.js: "npm:^10.6.0"
checksum: 10/bd8dbd2c3b87d0fac7303cf05b52916b57717a02161f8c5e0c8b87e6c6c8628fcd92b422cf33756d21a85f493ac7440c1ab1fecbe9c14b9f3e82876d46654837
checksum: 10/331c18762e713b44eaa44ab215e98f1a8d16d698fcd94d9953b7904100ddec9863fbdae59cf54c65ce0c2212d9b9199cbae0dae0b08affa78995d66d3c426c0d
languageName: node
linkType: hard
"@formatjs/intl-pluralrules@npm:6.2.4":
version: 6.2.4
resolution: "@formatjs/intl-pluralrules@npm:6.2.4"
"@formatjs/intl-pluralrules@npm:6.3.1":
version: 6.3.1
resolution: "@formatjs/intl-pluralrules@npm:6.3.1"
dependencies:
"@formatjs/ecma402-abstract": "npm:3.1.2"
"@formatjs/bigdecimal": "npm:0.2.0"
"@formatjs/ecma402-abstract": "npm:3.2.0"
"@formatjs/intl-localematcher": "npm:0.8.2"
decimal.js: "npm:^10.6.0"
checksum: 10/4af89c8846280eab313ada52d1a3c4a707b0fa1bca29311a80686cf315233d19a4a141b79ddc815620eb5a123e3991f2fd9f92299c468d084b1444b445747dbe
checksum: 10/4b170e77e835c9df77968e3d8d7a64d84be299178b933d4e2a3817067d4a5d6e4c41624a091a3841d41226569544cb13c684b5727b59324e3a09b4f507c35408
languageName: node
linkType: hard
"@formatjs/intl-relativetimeformat@npm:12.2.4":
version: 12.2.4
resolution: "@formatjs/intl-relativetimeformat@npm:12.2.4"
"@formatjs/intl-relativetimeformat@npm:12.3.1":
version: 12.3.1
resolution: "@formatjs/intl-relativetimeformat@npm:12.3.1"
dependencies:
"@formatjs/ecma402-abstract": "npm:3.1.2"
"@formatjs/ecma402-abstract": "npm:3.2.0"
"@formatjs/intl-localematcher": "npm:0.8.2"
checksum: 10/cd6fd40216ce1f35679d707a7be54761397bac574f7f118fe6fccc46effedf7d035f332d16ea6a11c1cfaf409f8fcc0e5585c80a0a5cb766e10307e663dd072c
checksum: 10/787988d9a8cdc40d51bc29a294aa7aedc8272e2e0537426876cd9a51a35dabc9db0ca5ec4731bc75513cf02a724d6ae0a7f35802b5cdcf1e5a3b1230f2577390
languageName: node
linkType: hard
"@formatjs/intl-supportedvaluesof@npm:2.2.2":
version: 2.2.2
resolution: "@formatjs/intl-supportedvaluesof@npm:2.2.2"
"@formatjs/intl-supportedvaluesof@npm:2.3.0":
version: 2.3.0
resolution: "@formatjs/intl-supportedvaluesof@npm:2.3.0"
dependencies:
"@formatjs/ecma402-abstract": "npm:3.1.2"
"@formatjs/ecma402-abstract": "npm:3.2.0"
"@formatjs/fast-memoize": "npm:3.1.1"
checksum: 10/4d833835c76f84d11347e133d638904ce0a8bf821a377545636e7e4f52aa4f153979265cfb90cd39ccb8d5e28c379f123dbc9e2011aa40a456c9a5e7dbcb6010
checksum: 10/4d38a6049934b4009e030278a4155fa3c936492cc2b6170fc00f3254842ff9cd53a291b998801e726f4ba6ccc55e09cfa7c027e8de952bbb2ea9dd0e1965da93
languageName: node
linkType: hard
@@ -8861,15 +8868,15 @@ __metadata:
"@codemirror/view": "npm:6.40.0"
"@date-fns/tz": "npm:1.4.1"
"@egjs/hammerjs": "npm:2.0.17"
"@formatjs/intl-datetimeformat": "npm:7.2.6"
"@formatjs/intl-displaynames": "npm:7.2.3"
"@formatjs/intl-durationformat": "npm:0.10.2"
"@formatjs/intl-datetimeformat": "npm:7.3.1"
"@formatjs/intl-displaynames": "npm:7.3.1"
"@formatjs/intl-durationformat": "npm:0.10.3"
"@formatjs/intl-getcanonicallocales": "npm:3.2.2"
"@formatjs/intl-listformat": "npm:8.2.3"
"@formatjs/intl-locale": "npm:5.2.2"
"@formatjs/intl-numberformat": "npm:9.2.4"
"@formatjs/intl-pluralrules": "npm:6.2.4"
"@formatjs/intl-relativetimeformat": "npm:12.2.4"
"@formatjs/intl-listformat": "npm:8.3.1"
"@formatjs/intl-locale": "npm:5.3.1"
"@formatjs/intl-numberformat": "npm:9.3.1"
"@formatjs/intl-pluralrules": "npm:6.3.1"
"@formatjs/intl-relativetimeformat": "npm:12.3.1"
"@fullcalendar/core": "npm:6.1.20"
"@fullcalendar/daygrid": "npm:6.1.20"
"@fullcalendar/interaction": "npm:6.1.20"
@@ -8981,7 +8988,7 @@ __metadata:
html-minifier-terser: "npm:7.2.0"
husky: "npm:9.1.7"
idb-keyval: "npm:6.2.2"
intl-messageformat: "npm:11.1.3"
intl-messageformat: "npm:11.2.0"
js-yaml: "npm:4.1.1"
jsdom: "npm:29.0.1"
jszip: "npm:3.10.1"
@@ -8996,7 +9003,7 @@ __metadata:
lodash.template: "npm:4.5.0"
luxon: "npm:3.7.2"
map-stream: "npm:0.0.7"
marked: "npm:17.0.4"
marked: "npm:17.0.5"
memoize-one: "npm:6.0.0"
node-vibrant: "npm:4.0.4"
object-hash: "npm:3.0.0"
@@ -9386,14 +9393,14 @@ __metadata:
languageName: node
linkType: hard
"intl-messageformat@npm:11.1.3":
version: 11.1.3
resolution: "intl-messageformat@npm:11.1.3"
"intl-messageformat@npm:11.2.0":
version: 11.2.0
resolution: "intl-messageformat@npm:11.2.0"
dependencies:
"@formatjs/ecma402-abstract": "npm:3.1.2"
"@formatjs/ecma402-abstract": "npm:3.2.0"
"@formatjs/fast-memoize": "npm:3.1.1"
"@formatjs/icu-messageformat-parser": "npm:3.5.2"
checksum: 10/db8eca63d9f14c624202fbb579e5b28813430b03792bc6422fda30d7cb6a24ef8bcb60440ea6dce50d3f35d3e697928609c4b7822853fd25713f1c0ad9c55161
"@formatjs/icu-messageformat-parser": "npm:3.5.3"
checksum: 10/a2aa4929aacb92aa1ae0a21adf40e9c5774ea8d97a3617443212ae7075d45103d49afb85885bd5cf5e5da94c0a5ee38219b55dccca46f21f7fb671e4549c36f2
languageName: node
linkType: hard
@@ -10696,12 +10703,12 @@ __metadata:
languageName: node
linkType: hard
"marked@npm:17.0.4":
version: 17.0.4
resolution: "marked@npm:17.0.4"
"marked@npm:17.0.5":
version: 17.0.5
resolution: "marked@npm:17.0.5"
bin:
marked: bin/marked.js
checksum: 10/a26c24db0882be6301d3947cee6e9fa70350ab9593cdf0c8c301ce64bb6125db158729fd5c7073106d0f176db6cb04749db19a0752637b071d5ec318d266f928
checksum: 10/d8b364c9dc165a31d4809abdc00588ef256af6b52a01a1bd84d79383a8f235f24f71deff32459561ca7c9332f43fd139846d37230c609186b0ba6b0234ffbc0a
languageName: node
linkType: hard