Compare commits

...

17 Commits

Author SHA1 Message Date
Aidan Timson
4e6fb3c103 Scale small based on date length 2026-01-13 10:52:35 +00:00
Aidan Timson
4b8cf9a056 Sizing (CSS Impl) 2026-01-13 10:40:36 +00:00
Aidan Timson
bcac688538 Sizing (JS Impl) 2026-01-13 10:40:09 +00:00
Aidan Timson
616209c0f0 Format 2026-01-13 08:57:33 +00:00
Aidan Timson
21ba3952d9 Add 2026-01-13 08:57:33 +00:00
Aidan Timson
7392e05230 Improve 2026-01-13 08:57:33 +00:00
Aidan Timson
74de8365eb Type 2026-01-13 08:57:33 +00:00
Aidan Timson
999d54147d Setup 2026-01-13 08:57:33 +00:00
Aidan Timson
721d0237ac Match 2026-01-13 08:57:33 +00:00
Aidan Timson
a3ad223230 Setup analog clock 2026-01-13 08:57:33 +00:00
Aidan Timson
69290a2ffb Add date to digital clock 2026-01-13 08:57:33 +00:00
Aidan Timson
c840af63f5 Setup 2026-01-13 08:57:33 +00:00
ildar170975
1beca4bfa6 ha-data-table: issues with "numeric" column (#28916)
Co-authored-by: uptimeZERO_ <pavilionsahota@gmail.com>
2026-01-13 08:38:15 +00:00
Kristel
82ab29cfc5 Add "Voice assistant" filter to helpers, automations, scenes and scripts pages (#28914) 2026-01-13 08:29:28 +00:00
Simon Lamon
3579c66f71 Update dropdown adjustments (#28294) 2026-01-13 08:54:17 +01:00
ildar170975
c042a8e310 ha-sidebar: remove scrollIntoViewIfNeeded() (#28938)
remove scrollIntoViewIfNeeded()
2026-01-13 07:23:20 +01:00
renovate[bot]
8d2794a4ee Update dependency vite-tsconfig-paths to v6.0.4 (#28952)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 07:15:15 +01:00
16 changed files with 566 additions and 150 deletions

View File

@@ -216,7 +216,7 @@
"ts-lit-plugin": "2.0.2",
"typescript": "5.9.3",
"typescript-eslint": "8.52.0",
"vite-tsconfig-paths": "6.0.3",
"vite-tsconfig-paths": "6.0.4",
"vitest": "4.0.16",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",

View File

@@ -1364,6 +1364,9 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell > * {
transition: var(--float-start) 0.2s ease;
}
.mdc-data-table__header-cell--numeric > span {
transition: none;
}
.mdc-data-table__header-cell ha-svg-icon {
top: -3px;
position: absolute;

View File

@@ -14,7 +14,6 @@ import "./ha-icon-button";
import "./ha-label";
import "./ha-list";
import "./ha-list-item";
import "./search-input-outlined";
import "./voice-assistant-brand-icon";
import { voiceAssistants } from "../data/expose";
import "../panels/config/voice-assistants/expose/expose-assistant-icon";

View File

@@ -52,8 +52,6 @@ import "./ha-spinner";
import "./ha-svg-icon";
import "./user/ha-user-badge";
const SUPPORT_SCROLL_IF_NEEDED = "scrollIntoViewIfNeeded" in document.body;
const SORT_VALUE_URL_PATHS = {
energy: 1,
map: 2,
@@ -344,17 +342,6 @@ class HaSidebar extends SubscribeMixin(LitElement) {
}
this._calculateCounts();
if (!SUPPORT_SCROLL_IF_NEEDED) {
return;
}
if (oldHass?.panelUrl !== this.hass.panelUrl) {
const selectedEl = this.shadowRoot!.querySelector(".selected");
if (selectedEl) {
// @ts-ignore
selectedEl.scrollIntoViewIfNeeded();
}
}
}
private _calculateCounts = throttle(() => {

View File

@@ -57,6 +57,7 @@ import "../../../components/ha-filter-devices";
import "../../../components/ha-filter-entities";
import "../../../components/ha-filter-floor-areas";
import "../../../components/ha-filter-labels";
import "../../../components/ha-filter-voice-assistants";
import "../../../components/ha-icon-button";
import "../../../components/ha-md-divider";
import "../../../components/ha-md-menu";
@@ -660,6 +661,15 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-categories>
<ha-filter-voice-assistants
.hass=${this.hass}
.value=${this._filters["ha-filter-voice-assistants"]?.value}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-voice-assistants"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-voice-assistants>
<ha-filter-blueprints
.hass=${this.hass}
.type=${"automation"}
@@ -1030,8 +1040,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
? // @ts-ignore
items.intersection(categoryItems)
: new Set([...items].filter((x) => categoryItems!.has(x)));
}
if (
} else if (
key === "ha-filter-labels" &&
Array.isArray(filter.value) &&
filter.value.length
@@ -1053,6 +1062,29 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
? // @ts-ignore
items.intersection(labelItems)
: new Set([...items].filter((x) => labelItems!.has(x)));
} else if (
key === "ha-filter-voice-assistants" &&
Array.isArray(filter.value) &&
filter.value.length
) {
const assistItems = new Set<string>();
this.automations
.filter((automation) =>
getEntityVoiceAssistantsIds(
this._entityReg,
automation.entity_id
).some((va) => (filter.value as string[]).includes(va))
)
.forEach((automation) => assistItems.add(automation.entity_id));
if (!items) {
items = assistItems;
continue;
}
items =
"intersection" in items
? // @ts-ignore
items.intersection(assistItems)
: new Set([...items].filter((x) => assistItems!.has(x)));
}
}
this._filteredAutomations = items ? [...items] : undefined;

View File

@@ -206,8 +206,8 @@ class HaBlueprintOverview extends LitElement {
sortable: true,
valueColumn: "usageCount",
type: "numeric",
minWidth: "100px",
maxWidth: "120px",
minWidth: "90px",
maxWidth: "90px",
template: (blueprint) => {
const count = blueprint.usageCount ?? 0;
return html`

View File

@@ -1,19 +1,16 @@
import type { RequestSelectedDetail } from "@material/mwc-list/mwc-list-item";
import { mdiDotsVertical, mdiRefresh } from "@mdi/js";
import {
mdiDotsVertical,
mdiLocationEnter,
mdiLocationExit,
mdiRefresh,
} from "@mdi/js";
import type { HassEntities } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event";
import "../../../components/ha-alert";
import "../../../components/ha-bar";
import "../../../components/ha-button-menu";
import "../../../components/ha-card";
import "../../../components/ha-check-list-item";
import "../../../components/ha-list-item";
import "../../../components/ha-metric";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import type {
HassioSupervisorInfo,
@@ -33,6 +30,9 @@ import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types";
import "../dashboard/ha-config-updates";
import { showJoinBetaDialog } from "./updates/show-dialog-join-beta";
import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "@home-assistant/webawesome/dist/components/divider/divider";
@customElement("ha-config-section-updates")
class HaConfigSectionUpdates extends LitElement {
@@ -77,35 +77,45 @@ class HaConfigSectionUpdates extends LitElement {
.path=${mdiRefresh}
@click=${this._checkUpdates}
></ha-icon-button>
<ha-button-menu multi>
<ha-dropdown @wa-select=${this._handleOverflowAction}>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-check-list-item
left
@request-selected=${this._toggleSkipped}
.selected=${this._showSkipped}
<ha-dropdown-item
type="checkbox"
.checked=${this._showSkipped}
value="show_skipped"
>
${this.hass.localize("ui.panel.config.updates.show_skipped")}
</ha-check-list-item>
</ha-dropdown-item>
${this._supervisorInfo
? html`
<li divider role="separator"></li>
<ha-list-item
@request-selected=${this._toggleBeta}
<wa-divider></wa-divider>
<ha-dropdown-item
value="toggle_beta"
.disabled=${this._supervisorInfo.channel === "dev"}
>
<ha-svg-icon
.path=${this._supervisorInfo.channel === "stable"
? mdiLocationEnter
: mdiLocationExit}
slot="icon"
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.updates.${this._supervisorInfo.channel === "stable" ? "join" : "leave"}_beta`
)}
${this._supervisorInfo.channel === "stable"
? this.hass.localize("ui.panel.config.updates.join_beta")
: this.hass.localize(
"ui.panel.config.updates.leave_beta"
)}
</ha-list-item>
</ha-dropdown-item>
`
: nothing}
</ha-button-menu>
</ha-dropdown>
</div>
<div class="content">
${canInstallUpdates.length
@@ -156,27 +166,19 @@ class HaConfigSectionUpdates extends LitElement {
this._supervisorInfo = await fetchHassioSupervisorInfo(this.hass);
}
private _toggleSkipped(ev: CustomEvent<RequestSelectedDetail>): void {
if (ev.detail.source !== "property") {
return;
}
this._showSkipped = !this._showSkipped;
}
private async _toggleBeta(
ev: CustomEvent<RequestSelectedDetail>
): Promise<void> {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
}
if (this._supervisorInfo!.channel === "stable") {
showJoinBetaDialog(this, {
join: async () => this._setChannel("beta"),
});
} else {
this._setChannel("stable");
private _handleOverflowAction(
ev: CustomEvent<{ item: { value: string } }>
): void {
if (ev.detail.item.value === "toggle_beta") {
if (this._supervisorInfo!.channel === "stable") {
showJoinBetaDialog(this, {
join: () => this._setChannel("beta"),
});
} else {
this._setChannel("stable");
}
} else if (ev.detail.item.value === "show_skipped") {
this._showSkipped = !this._showSkipped;
}
}

View File

@@ -51,6 +51,7 @@ import "../../../components/ha-filter-devices";
import "../../../components/ha-filter-entities";
import "../../../components/ha-filter-floor-areas";
import "../../../components/ha-filter-labels";
import "../../../components/ha-filter-voice-assistants";
import "../../../components/ha-icon";
import "../../../components/ha-icon-overflow-menu";
import "../../../components/ha-md-divider";
@@ -207,7 +208,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
})
private _activeHiddenColumns?: string[];
@state() private _stateItems: HassEntity[] = [];
@state() private _helperEntities: HassEntity[] = [];
@state() private _disabledEntityEntries?: EntityRegistryEntry[];
@@ -249,7 +250,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg!: EntityRegistryEntry[];
@state() private _filteredStateItems?: string[] | null;
@state() private _filteredHelperEntityIds?: string[] | null;
private _sizeController = new ResizeController(this, {
callback: (entries) => entries[0]?.contentRect.width,
@@ -640,7 +641,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
protected render(): TemplateResult {
if (
!this.hass ||
this._stateItems === undefined ||
this._helperEntities === undefined ||
this._entityEntries === undefined ||
this._configEntries === undefined
) {
@@ -715,14 +716,14 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
(!this._sizeController.value && this.hass.dockedSidebar === "docked");
const helpers = this._getItems(
this.hass.localize,
this._stateItems,
this._helperEntities,
this._disabledEntityEntries || [],
this._entityEntries,
this._configEntries,
this._entityReg,
this._categories,
this._labels,
this._filteredStateItems
this._filteredHelperEntityIds
);
return html`
<hass-tabs-subpage-data-table
@@ -809,6 +810,15 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-categories>
<ha-filter-voice-assistants
.hass=${this.hass}
.value=${this._filters["ha-filter-voice-assistants"]}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-voice-assistants"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-voice-assistants>
${!this.narrow
? html`<ha-md-button-menu slot="selection-bar">
@@ -971,7 +981,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
filter.length
) {
const labelItems = new Set<string>();
this._stateItems
this._helperEntities
.filter((stateItem) =>
entityRegistryByEntityId(this._entityReg)[
stateItem.entity_id
@@ -990,14 +1000,13 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
? // @ts-ignore
items.intersection(labelItems)
: new Set([...items].filter((x) => labelItems!.has(x)));
}
if (
} else if (
key === "ha-filter-categories" &&
Array.isArray(filter) &&
filter.length
) {
const categoryItems = new Set<string>();
this._stateItems
this._helperEntities
.filter(
(stateItem) =>
filter[0] ===
@@ -1017,10 +1026,39 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
? // @ts-ignore
items.intersection(categoryItems)
: new Set([...items].filter((x) => categoryItems!.has(x)));
} else if (
key === "ha-filter-voice-assistants" &&
Array.isArray(filter) &&
filter.length
) {
const assistItems = new Set<string>();
this._helperEntities
.filter((stateItem) =>
getEntityVoiceAssistantsIds(
this._entityReg,
stateItem.entity_id
).some((va) => (filter as string[]).includes(va))
)
.forEach((stateItem) => assistItems.add(stateItem.entity_id));
(this._disabledEntityEntries || [])
.filter((entry) =>
getEntityVoiceAssistantsIds(this._entityReg, entry.entity_id).some(
(va) => (filter as string[]).includes(va)
)
)
.forEach((entry) => assistItems.add(entry.entity_id));
if (!items) {
items = assistItems;
continue;
}
items =
"intersection" in items
? // @ts-ignore
items.intersection(assistItems)
: new Set([...items].filter((x) => assistItems!.has(x)));
}
}
this._filteredStateItems = items ? [...items] : undefined;
this._filteredHelperEntityIds = items ? [...items] : undefined;
}
public connectedCallback() {
@@ -1053,6 +1091,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
const device = this._searchParms.get("device");
const label = this._searchParms.get("label");
const category = this._searchParms.get("category");
const voiceAssistant = this._searchParms.get("voice_assistant");
if (!category && !label && !device) {
return;
@@ -1064,6 +1103,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
"ha-filter-devices": device ? [device] : [],
"ha-filter-labels": label ? [label] : [],
"ha-filter-categories": category ? [category] : [],
"ha-filter-voice-assistants": voiceAssistant ? [voiceAssistant] : [],
};
}
@@ -1303,7 +1343,7 @@ ${rejected
}
let changed =
!this._stateItems ||
!this._helperEntities ||
changedProps.has("_entityEntries") ||
changedProps.has("_configEntries") ||
changedProps.has("_entitySource");
@@ -1318,17 +1358,17 @@ ${rejected
const entityIds = Object.keys(this._entitySource);
const newStates = Object.values(this.hass!.states).filter(
const newHelpers = Object.values(this.hass!.states).filter(
(entity) =>
entityIds.includes(entity.entity_id) ||
isHelperDomain(computeStateDomain(entity))
);
if (
this._stateItems.length !== newStates.length ||
!this._stateItems.every((val, idx) => newStates[idx] === val)
this._helperEntities.length !== newHelpers.length ||
!this._helperEntities.every((val, idx) => newHelpers[idx] === val)
) {
this._stateItems = newStates;
this._helperEntities = newHelpers;
}
}

View File

@@ -51,6 +51,7 @@ import "../../../components/ha-filter-devices";
import "../../../components/ha-filter-entities";
import "../../../components/ha-filter-floor-areas";
import "../../../components/ha-filter-labels";
import "../../../components/ha-filter-voice-assistants";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-overflow-menu";
import "../../../components/ha-md-divider";
@@ -679,6 +680,15 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-categories>
<ha-filter-voice-assistants
.hass=${this.hass}
.value=${this._filters["ha-filter-voice-assistants"]?.value}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-voice-assistants"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-voice-assistants>
${!this.narrow
? html`<ha-md-button-menu slot="selection-bar">
@@ -914,8 +924,7 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
? // @ts-ignore
items.intersection(categoryItems)
: new Set([...items].filter((x) => categoryItems!.has(x)));
}
if (
} else if (
key === "ha-filter-labels" &&
Array.isArray(filter.value) &&
filter.value.length
@@ -937,6 +946,28 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
? // @ts-ignore
items.intersection(labelItems)
: new Set([...items].filter((x) => labelItems!.has(x)));
} else if (
key === "ha-filter-voice-assistants" &&
Array.isArray(filter.value) &&
filter.value.length
) {
const assistItems = new Set<string>();
this.scenes
.filter((scene) =>
getEntityVoiceAssistantsIds(this._entityReg, scene.entity_id).some(
(va) => (filter.value as string[]).includes(va)
)
)
.forEach((scene) => assistItems.add(scene.entity_id));
if (!items) {
items = assistItems;
continue;
}
items =
"intersection" in items
? // @ts-ignore
items.intersection(assistItems)
: new Set([...items].filter((x) => assistItems!.has(x)));
}
}
this._filteredScenes = items ? [...items] : undefined;

View File

@@ -53,6 +53,7 @@ import "../../../components/ha-filter-devices";
import "../../../components/ha-filter-entities";
import "../../../components/ha-filter-floor-areas";
import "../../../components/ha-filter-labels";
import "../../../components/ha-filter-voice-assistants";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-overflow-menu";
import "../../../components/ha-md-divider";
@@ -661,6 +662,15 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-categories>
<ha-filter-voice-assistants
.hass=${this.hass}
.value=${this._filters["ha-filter-voice-assistants"]?.value}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-voice-assistants"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-voice-assistants>
<ha-filter-blueprints
.hass=${this.hass}
.type=${"script"}
@@ -919,8 +929,7 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
? // @ts-ignore
items.intersection(categoryItems)
: new Set([...items].filter((x) => categoryItems!.has(x)));
}
if (
} else if (
key === "ha-filter-labels" &&
Array.isArray(filter.value) &&
filter.value.length
@@ -942,6 +951,28 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
? // @ts-ignore
items.intersection(labelItems)
: new Set([...items].filter((x) => labelItems!.has(x)));
} else if (
key === "ha-filter-voice-assistants" &&
Array.isArray(filter.value) &&
filter.value.length
) {
const assistItems = new Set<string>();
this.scripts
.filter((script) =>
getEntityVoiceAssistantsIds(this._entityReg, script.entity_id).some(
(va) => (filter.value as string[]).includes(va)
)
)
.forEach((script) => assistItems.add(script.entity_id));
if (!items) {
items = assistItems;
continue;
}
items =
"intersection" in items
? // @ts-ignore
items.intersection(assistItems)
: new Set([...items].filter((x) => assistItems!.has(x)));
}
}
this._filteredScripts = items ? [...items] : undefined;

View File

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

View File

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

View File

@@ -421,6 +421,7 @@ export interface ClockCardConfig extends LovelaceCardConfig {
time_format?: TimeFormat;
time_zone?: string;
no_background?: boolean;
date?: "none" | "short" | "long" | "day" | "day-month" | "day-month-long";
// Analog clock options
border?: boolean;
ticks?: "none" | "quarter" | "hour" | "minute";

View File

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

View File

@@ -8241,6 +8241,18 @@
"large": "Large"
},
"show_seconds": "Display seconds",
"date": {
"label": "Date",
"description": "Whether to show the date on the clock. Can also be a custom date format."
},
"dates": {
"none": "No date",
"short": "Short",
"long": "Long",
"day": "Day only",
"day-month": "Day and month",
"day-month-long": "Day and month (long)"
},
"time_format": "[%key:ui::panel::profile::time_format::dropdown_label%]",
"time_formats": {
"auto": "Use user settings",

View File

@@ -9175,7 +9175,7 @@ __metadata:
typescript: "npm:5.9.3"
typescript-eslint: "npm:8.52.0"
ua-parser-js: "npm:2.0.7"
vite-tsconfig-paths: "npm:6.0.3"
vite-tsconfig-paths: "npm:6.0.4"
vitest: "npm:4.0.16"
vue: "npm:2.7.16"
vue2-daterange-picker: "npm:0.6.8"
@@ -14426,25 +14426,26 @@ __metadata:
languageName: node
linkType: hard
"vite-tsconfig-paths@npm:6.0.3":
version: 6.0.3
resolution: "vite-tsconfig-paths@npm:6.0.3"
"vite-tsconfig-paths@npm:6.0.4":
version: 6.0.4
resolution: "vite-tsconfig-paths@npm:6.0.4"
dependencies:
debug: "npm:^4.1.1"
globrex: "npm:^0.1.2"
tsconfck: "npm:^3.0.3"
vite: "npm:*"
peerDependencies:
vite: "*"
peerDependenciesMeta:
vite:
optional: true
checksum: 10/271cdc040a03181d13e4253e99ec6deec1cbe4ec4cb2a1377d46dba403483f8e38a5c2ed29ad0493ed51c2cfd658847cb1cdc73e438d6a8bcdd2b52df19d72e2
checksum: 10/85f871cd5e321f2865972559b01c518664e6e34f9039b630dd77c2f379f8fdc386e15f7237aa5c108d813030c6e9bc8edfbf61687df7684803111a2495edadac
languageName: node
linkType: hard
"vite@npm:^6.0.0 || ^7.0.0":
version: 7.3.0
resolution: "vite@npm:7.3.0"
"vite@npm:*, vite@npm:^6.0.0 || ^7.0.0":
version: 7.3.1
resolution: "vite@npm:7.3.1"
dependencies:
esbuild: "npm:^0.27.0"
fdir: "npm:^6.5.0"
@@ -14493,7 +14494,7 @@ __metadata:
optional: true
bin:
vite: bin/vite.js
checksum: 10/044490133aaf4cc024700995edaac10ff182262c721a44a8ac7839207b3e5af435c8b9dd8ee4658dc0f47147fa4211632cca3120177aa4bab99a7cb095e5149e
checksum: 10/62e48ffa4283b688f0049005405a004447ad38ffc99a0efea4c3aa9b7eed739f7402b43f00668c0ee5a895b684dc953d62f0722d8a92c5b2f6c95f051bceb208
languageName: node
linkType: hard