Compare commits

..

5 Commits

Author SHA1 Message Date
Aidan Timson
c6c2d47ee3 Add support for error state 2025-10-29 09:03:13 +00:00
Aidan Timson
01ba7571f0 Restore optimised parts code 2025-10-28 16:01:49 +00:00
Aidan Timson
70bb29e242 memo 2025-10-27 16:26:29 +00:00
Aidan Timson
34443a009d Reorder 2025-10-27 16:21:17 +00:00
Aidan Timson
e1dce358c3 Migrate ha-icon-picker to generic picker 2025-10-27 16:19:49 +00:00
28 changed files with 232 additions and 831 deletions

2
.nvmrc
View File

@@ -1 +1 @@
22.21.1
22.21.0

View File

@@ -5,14 +5,14 @@ subtitle: Dialogs provide important prompts in a user flow.
# Material Design 3
Our dialogs are based on the latest version of Material Design. Please note that we have made some well-considered adjustments to these guidelines. Specs and guidelines can be found on its [website](https://m3.material.io/components/dialogs/overview).
Our dialogs are based on the latest version of Material Design. Please note that we have made some well-considered adjustments to these guideliness. Specs and guidelines can be found on its [website](https://m3.material.io/components/dialogs/overview).
# Guidelines
## Design
- Dialogs have a max width of 560px. Alert and confirmation dialogs have a fixed width of 320px. If you need more width, consider a dedicated page instead.
- The close X-icon is on the top left, on all screen sizes. Except for alert and confirmation dialogs, they only have buttons and no X-icon. This is different compared to the Material guidelines.
- Dialogs have a max width of 560px. Alert and confirmation dialogs got a fixed width of 320px. If you need more width, consider a dedicated page instead.
- The close X-icon is on the top left, on all screen sizes. Except for alert and confirmation dialogs, they only have buttons and no X-icon. This is different compared to the Material guideliness.
- Dialogs can't be closed with ESC or clicked outside of the dialog when there is a form that the user needs to fill out. Instead it will animate "no" by a little shake.
- Extra icon buttons are on the top right, for example help, settings and expand dialog. More than 2 icon buttons, they will be in an overflow menu.
- The submit button is grouped with a cancel button at the bottom right, on all screen sizes. Fullscreen mobile dialogs have them sticky at the bottom.
@@ -26,7 +26,7 @@ Our dialogs are based on the latest version of Material Design. Please note that
- A best practice is to always use a title, even if it is optional by Material guidelines.
- People mainly read the title and a button. Put the most important information in those two.
- Try to avoid user generated content in the title, this could make the title unreadably long.
- Try to avoid user generated content in the title, this could make the title unreadable long.
- If users become unsure, they read the description. Make sure this explains what will happen.
- Strive for minimalism.

View File

@@ -53,7 +53,7 @@
"@fullcalendar/luxon3": "6.1.19",
"@fullcalendar/timegrid": "6.1.19",
"@home-assistant/webawesome": "3.0.0-beta.6.ha.6",
"@lezer/highlight": "1.2.3",
"@lezer/highlight": "1.2.2",
"@lit-labs/motion": "1.0.9",
"@lit-labs/observers": "2.0.6",
"@lit-labs/virtualizer": "2.1.1",

View File

@@ -1,116 +0,0 @@
export interface SwipeGestureResult {
velocity: number;
delta: number;
isSwipe: boolean;
isDownwardSwipe: boolean;
}
export interface SwipeGestureConfig {
velocitySwipeThreshold?: number;
movementTimeThreshold?: number;
}
const VELOCITY_SWIPE_THRESHOLD = 0.5; // px/ms
const MOVEMENT_TIME_THRESHOLD = 100; // ms
/**
* Recognizes swipe gestures and calculates velocity for touch interactions.
* Tracks touch movement and provides velocity-based and position-based gesture detection.
*/
export class SwipeGestureRecognizer {
private _startY = 0;
private _delta = 0;
private _startTime = 0;
private _lastY = 0;
private _lastTime = 0;
private _velocityThreshold: number;
private _movementTimeThreshold: number;
constructor(config: SwipeGestureConfig = {}) {
this._velocityThreshold =
config.velocitySwipeThreshold ?? VELOCITY_SWIPE_THRESHOLD; // px/ms
this._movementTimeThreshold =
config.movementTimeThreshold ?? MOVEMENT_TIME_THRESHOLD; // ms
}
/**
* Initialize gesture tracking with starting touch position
*/
public start(clientY: number): void {
const now = Date.now();
this._startY = clientY;
this._startTime = now;
this._lastY = clientY;
this._lastTime = now;
this._delta = 0;
}
/**
* Update gesture state during movement
* Returns the current delta (negative when dragging down)
*/
public move(clientY: number): number {
const now = Date.now();
this._delta = this._startY - clientY;
this._lastY = clientY;
this._lastTime = now;
return this._delta;
}
/**
* Calculate final gesture result when touch ends
*/
public end(): SwipeGestureResult {
const velocity = this.getVelocity();
const hasSignificantVelocity = Math.abs(velocity) > this._velocityThreshold;
return {
velocity,
delta: this._delta,
isSwipe: hasSignificantVelocity,
isDownwardSwipe: velocity > 0,
};
}
/**
* Get current drag delta (negative when dragging down)
*/
public getDelta(): number {
return this._delta;
}
/**
* Calculate velocity based on recent movement
* Returns 0 if no recent movement detected
* Positive velocity means downward swipe
*/
public getVelocity(): number {
const now = Date.now();
const timeSinceLastMove = now - this._lastTime;
// Only consider velocity if the last movement was recent
if (timeSinceLastMove >= this._movementTimeThreshold) {
return 0;
}
const timeDelta = this._lastTime - this._startTime;
return timeDelta > 0 ? (this._lastY - this._startY) / timeDelta : 0;
}
/**
* Reset all tracking state
*/
public reset(): void {
this._startY = 0;
this._delta = 0;
this._startTime = 0;
this._lastY = 0;
this._lastTime = 0;
}
}

View File

@@ -1 +0,0 @@
export const ANY_STATE_VALUE = "__ANY_STATE_IGNORE_ATTRIBUTES__";

View File

@@ -4,7 +4,6 @@ import { customElement, property } from "lit/decorators";
import { keyed } from "lit/directives/keyed";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../../common/dom/fire_event";
import { ANY_STATE_VALUE } from "./const";
import { ensureArray } from "../../common/array/ensure-array";
import type { HomeAssistant } from "../../types";
import "./ha-entity-state-picker";
@@ -58,7 +57,6 @@ export class HaEntityStatesPicker extends LitElement {
const value = this.value || [];
const hide = [...(this.hideStates || []), ...value];
const hideValue = value.includes(ANY_STATE_VALUE);
return html`
${repeat(
@@ -86,7 +84,7 @@ export class HaEntityStatesPicker extends LitElement {
`
)}
<div>
${(this.disabled && value.length) || hideValue
${this.disabled && value.length
? nothing
: keyed(
value.length,

View File

@@ -1,7 +1,6 @@
import "@home-assistant/webawesome/dist/components/drawer/drawer";
import { css, html, LitElement, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { SwipeGestureRecognizer } from "../common/util/swipe-gesture-recognizer";
import { customElement, property, state } from "lit/decorators";
import { haStyleScrollbar } from "../resources/styles";
export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300;
@@ -15,12 +14,6 @@ export class HaBottomSheet extends LitElement {
@state() private _drawerOpen = false;
@query("#drawer") private _drawer!: HTMLElement;
private _gestureRecognizer = new SwipeGestureRecognizer();
private _isDragging = false;
private _handleAfterHide() {
this.open = false;
const ev = new Event("closed", {
@@ -40,132 +33,19 @@ export class HaBottomSheet extends LitElement {
render() {
return html`
<wa-drawer
id="drawer"
placement="bottom"
.open=${this._drawerOpen}
@wa-after-hide=${this._handleAfterHide}
without-header
@touchstart=${this._handleTouchStart}
>
<slot name="header"></slot>
<div id="body" class="body ha-scrollbar">
<div class="body ha-scrollbar">
<slot></slot>
</div>
</wa-drawer>
`;
}
private _handleTouchStart = (ev: TouchEvent) => {
// Check if any element inside drawer in the composed path has scrollTop > 0
for (const path of ev.composedPath()) {
const el = path as HTMLElement;
if (el === this._drawer) {
break;
}
if (el.scrollTop > 0) {
return;
}
}
this._startResizing(ev.touches[0].clientY);
};
private _startResizing(clientY: number) {
// register event listeners for drag handling
document.addEventListener("touchmove", this._handleTouchMove, {
passive: false,
});
document.addEventListener("touchend", this._handleTouchEnd);
document.addEventListener("touchcancel", this._handleTouchEnd);
this._gestureRecognizer.start(clientY);
}
private _handleTouchMove = (ev: TouchEvent) => {
const currentY = ev.touches[0].clientY;
const delta = this._gestureRecognizer.move(currentY);
if (delta < 0) {
ev.preventDefault();
this._isDragging = true;
requestAnimationFrame(() => {
if (this._isDragging) {
this.style.setProperty(
"--dialog-transform",
`translateY(${delta * -1}px)`
);
}
});
}
};
private _animateSnapBack() {
// Add transition for smooth animation
this.style.setProperty(
"--dialog-transition",
`transform ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms ease-out`
);
// Reset transform to snap back
this.style.removeProperty("--dialog-transform");
// Remove transition after animation completes
setTimeout(() => {
this.style.removeProperty("--dialog-transition");
}, BOTTOM_SHEET_ANIMATION_DURATION_MS);
}
private _handleTouchEnd = () => {
this._unregisterResizeHandlers();
this._isDragging = false;
const result = this._gestureRecognizer.end();
// If velocity exceeds threshold, use velocity direction to determine action
if (result.isSwipe) {
if (result.isDownwardSwipe) {
// Downward swipe - close the bottom sheet
this._drawerOpen = false;
} else {
// Upward swipe - keep open and animate back
this._animateSnapBack();
}
return;
}
// If velocity is below threshold, use position-based logic
// Get the drawer height to calculate 50% threshold
const drawerBody = this._drawer.shadowRoot?.querySelector(
'[part="body"]'
) as HTMLElement;
const drawerHeight = drawerBody?.offsetHeight || 0;
// delta is negative when dragging down
// Close if dragged down past 50% of the drawer height
if (
drawerHeight > 0 &&
result.delta < 0 &&
Math.abs(result.delta) > drawerHeight * 0.5
) {
this._drawerOpen = false;
} else {
this._animateSnapBack();
}
};
private _unregisterResizeHandlers = () => {
document.removeEventListener("touchmove", this._handleTouchMove);
document.removeEventListener("touchend", this._handleTouchEnd);
document.removeEventListener("touchcancel", this._handleTouchEnd);
};
disconnectedCallback() {
super.disconnectedCallback();
this._unregisterResizeHandlers();
this._isDragging = false;
}
static styles = [
haStyleScrollbar,
css`
@@ -179,8 +59,6 @@ export class HaBottomSheet extends LitElement {
wa-drawer::part(dialog) {
max-height: var(--ha-bottom-sheet-max-height, 90vh);
align-items: center;
transform: var(--dialog-transform);
transition: var(--dialog-transition);
}
wa-drawer::part(body) {
max-width: var(--ha-bottom-sheet-max-width);
@@ -212,11 +90,6 @@ export class HaBottomSheet extends LitElement {
max-width: 100%;
display: flex;
flex-direction: column;
padding: var(
--ha-bottom-sheet-padding,
0 var(--safe-area-inset-right) var(--safe-area-inset-bottom)
var(--safe-area-inset-left)
);
}
`,
];

View File

@@ -103,6 +103,10 @@ export class HaGenericPicker extends LitElement {
// helper to set new value after closing picker, to avoid flicker
private _newValue?: string;
@property({ attribute: "error-message" }) public errorMessage?: string;
@property({ type: Boolean, reflect: true }) public invalid = false;
private _unsubscribeTinyKeys?: () => void;
protected render() {
@@ -137,6 +141,8 @@ export class HaGenericPicker extends LitElement {
.value=${this.value}
.required=${this.required}
.disabled=${this.disabled}
.errorMessage=${this.errorMessage}
.invalid=${this.invalid}
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${this.valueRenderer}
>
@@ -205,11 +211,16 @@ export class HaGenericPicker extends LitElement {
}
private _renderHelper() {
return this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
: nothing;
const showError = this.invalid && this.errorMessage;
const showHelper = !showError && this.helper;
if (!showError && !showHelper) {
return nothing;
}
return html`<ha-input-helper-text .disabled=${this.disabled}>
${showError ? this.errorMessage : this.helper}
</ha-input-helper-text>`;
}
private _dialogOpened = () => {
@@ -308,6 +319,9 @@ export class HaGenericPicker extends LitElement {
display: block;
margin: var(--ha-space-2) 0 0;
}
:host([invalid]) ha-input-helper-text {
color: var(--mdc-theme-error, var(--error-color, #b00020));
}
wa-popover {
--wa-space-l: var(--ha-space-0);

View File

@@ -1,8 +1,4 @@
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type {
ComboBoxDataProviderCallback,
ComboBoxDataProviderParams,
} from "@vaadin/combo-box/vaadin-combo-box-light";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
@@ -10,9 +6,10 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { customIcons } from "../data/custom_icons";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-combo-box";
import "./ha-icon";
import "./ha-combo-box-item";
import "./ha-generic-picker";
import "./ha-icon";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
interface IconItem {
icon: string;
@@ -21,7 +18,7 @@ interface IconItem {
}
interface RankedIcon {
icon: string;
item: PickerComboBoxItem;
rank: number;
}
@@ -67,13 +64,18 @@ const loadCustomIconItems = async (iconsetPrefix: string) => {
}
};
const rowRenderer: ComboBoxLitRenderer<IconItem | RankedIcon> = (item) => html`
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
<ha-combo-box-item type="button">
<ha-icon .icon=${item.icon} slot="start"></ha-icon>
${item.icon}
<ha-icon .icon=${item.id} slot="start"></ha-icon>
${item.id}
</ha-combo-box-item>
`;
const valueRenderer = (value: string) => html`
<ha-icon .icon=${value} slot="start"></ha-icon>
<span slot="headline">${value}</span>
`;
@customElement("ha-icon-picker")
export class HaIconPicker extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@@ -96,13 +98,11 @@ export class HaIconPicker extends LitElement {
protected render(): TemplateResult {
return html`
<ha-combo-box
<ha-generic-picker
.hass=${this.hass}
item-value-path="icon"
item-label-path="icon"
.value=${this._value}
allow-custom-value
.dataProvider=${ICONS_LOADED ? this._iconProvider : undefined}
.getItems=${this._getItems}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
@@ -110,69 +110,85 @@ export class HaIconPicker extends LitElement {
.placeholder=${this.placeholder}
.errorMessage=${this.errorMessage}
.invalid=${this.invalid}
.renderer=${rowRenderer}
icon
@opened-changed=${this._openedChanged}
.rowRenderer=${rowRenderer}
.valueRenderer=${valueRenderer}
.searchFn=${this._filterIcons}
.notFoundLabel=${this.hass?.localize(
"ui.components.icon-picker.no_match"
)}
popover-placement="bottom-start"
@value-changed=${this._valueChanged}
>
${this._value || this.placeholder
? html`
<ha-icon .icon=${this._value || this.placeholder} slot="icon">
</ha-icon>
`
: html`<slot slot="icon" name="fallback"></slot>`}
</ha-combo-box>
</ha-generic-picker>
`;
}
// Filter can take a significant chunk of frame (up to 3-5 ms)
private _filterIcons = memoizeOne(
(filter: string, iconItems: IconItem[] = ICONS) => {
(filter: string, items: PickerComboBoxItem[]): PickerComboBoxItem[] => {
if (!filter) {
return iconItems;
return items;
}
const filteredItems: RankedIcon[] = [];
const addIcon = (icon: string, rank: number) =>
filteredItems.push({ icon, rank });
const addIcon = (item: PickerComboBoxItem, rank: number) =>
filteredItems.push({ item, rank });
// Filter and rank such that exact matches rank higher, and prefer icon name matches over keywords
for (const item of iconItems) {
if (item.parts.has(filter)) {
addIcon(item.icon, 1);
} else if (item.keywords.includes(filter)) {
addIcon(item.icon, 2);
} else if (item.icon.includes(filter)) {
addIcon(item.icon, 3);
} else if (item.keywords.some((word) => word.includes(filter))) {
addIcon(item.icon, 4);
for (const item of items) {
const iconName = item.id.split(":")[1] || item.id;
const parts = iconName.split("-");
const keywords = item.search_labels?.slice(1) || [];
if (parts.includes(filter)) {
addIcon(item, 1);
} else if (keywords.includes(filter)) {
addIcon(item, 2);
} else if (item.id.includes(filter)) {
addIcon(item, 3);
} else if (keywords.some((word) => word.includes(filter))) {
addIcon(item, 4);
}
}
// Allow preview for custom icon not in list
if (filteredItems.length === 0) {
addIcon(filter, 0);
addIcon(
{
id: filter,
primary: filter,
icon: filter,
search_labels: [filter],
sorting_label: filter,
},
0
);
}
return filteredItems.sort((itemA, itemB) => itemA.rank - itemB.rank);
return filteredItems
.sort((itemA, itemB) => itemA.rank - itemB.rank)
.map((item) => item.item);
}
);
private _iconProvider = (
params: ComboBoxDataProviderParams,
callback: ComboBoxDataProviderCallback<IconItem | RankedIcon>
) => {
const filteredItems = this._filterIcons(params.filter.toLowerCase(), ICONS);
const iStart = params.page * params.pageSize;
const iEnd = iStart + params.pageSize;
callback(filteredItems.slice(iStart, iEnd), filteredItems.length);
};
private _getItems = (): PickerComboBoxItem[] =>
ICONS.map((icon: IconItem) => ({
id: icon.icon,
primary: icon.icon,
icon: icon.icon,
search_labels: [
icon.icon.split(":")[1] || icon.icon,
...Array.from(icon.parts),
...icon.keywords,
],
sorting_label: icon.icon,
}));
private async _openedChanged(ev: ValueChangedEvent<boolean>) {
const opened = ev.detail.value;
if (opened && !ICONS_LOADED) {
await loadIcons();
this.requestUpdate();
protected firstUpdated() {
if (!ICONS_LOADED) {
loadIcons().then(() => {
this.requestUpdate();
});
}
}
@@ -199,15 +215,9 @@ export class HaIconPicker extends LitElement {
}
static styles = css`
*[slot="icon"] {
color: var(--primary-text-color);
position: relative;
bottom: 2px;
}
*[slot="prefix"] {
margin-right: 8px;
margin-inline-end: 8px;
margin-inline-start: initial;
ha-generic-picker {
width: 100%;
display: block;
}
`;
}

View File

@@ -39,6 +39,10 @@ export class HaPickerField extends LitElement {
@property({ attribute: false })
public valueRenderer?: PickerValueRenderer;
@property({ attribute: "error-message" }) public errorMessage?: string;
@property({ type: Boolean, reflect: true }) public invalid = false;
@query("ha-combo-box-item", true) public item!: HaComboBoxItem;
public async focus() {
@@ -142,6 +146,11 @@ export class HaPickerField extends LitElement {
background-color: var(--mdc-theme-primary);
}
:host([invalid]) ha-combo-box-item:after {
height: 2px;
background-color: var(--mdc-theme-error, var(--error-color, #b00020));
}
.clear {
margin: 0 -8px;
--mdc-icon-button-size: 32px;

View File

@@ -435,9 +435,9 @@ export const convertStatisticsToHistory = (
Object.entries(orderedStatistics).forEach(([key, value]) => {
const entityHistoryStates: EntityHistoryState[] = value.map((e) => ({
s: e.mean != null ? e.mean.toString() : e.state!.toString(),
lc: e.end / 1000,
lc: e.start / 1000,
a: {},
lu: e.end / 1000,
lu: e.start / 1000,
}));
statsHistoryStates[key] = entityHistoryStates;
});

View File

@@ -484,7 +484,7 @@ class DataEntryFlowDialog extends LitElement {
this._unsubDataEntryFlowProgress = undefined;
}
if (_step.next_flow[0] === "config_flow") {
showConfigFlowDialog(this, {
showConfigFlowDialog(this._params!.dialogParentElement!, {
continueFlowId: _step.next_flow[1],
carryOverDevices: this._devices(
this._params!.flowConfig.showDevices,
@@ -496,23 +496,32 @@ class DataEntryFlowDialog extends LitElement {
});
} else if (_step.next_flow[0] === "options_flow") {
if (_step.type === "create_entry") {
showOptionsFlowDialog(this, _step.result!, {
continueFlowId: _step.next_flow[1],
navigateToResult: this._params!.navigateToResult,
dialogClosedCallback: this._params!.dialogClosedCallback,
});
showOptionsFlowDialog(
this._params!.dialogParentElement!,
_step.result!,
{
continueFlowId: _step.next_flow[1],
navigateToResult: this._params!.navigateToResult,
dialogClosedCallback: this._params!.dialogClosedCallback,
}
);
}
} else if (_step.next_flow[0] === "config_subentries_flow") {
if (_step.type === "create_entry") {
showSubConfigFlowDialog(this, _step.result!, _step.next_flow[0], {
continueFlowId: _step.next_flow[1],
navigateToResult: this._params!.navigateToResult,
dialogClosedCallback: this._params!.dialogClosedCallback,
});
showSubConfigFlowDialog(
this._params!.dialogParentElement!,
_step.result!,
_step.next_flow[0],
{
continueFlowId: _step.next_flow[1],
navigateToResult: this._params!.navigateToResult,
dialogClosedCallback: this._params!.dialogClosedCallback,
}
);
}
} else {
this.closeDialog();
showAlertDialog(this, {
showAlertDialog(this._params!.dialogParentElement!, {
text: this.hass.localize(
"ui.panel.config.integrations.config_flow.error",
{ error: `Unsupported next flow type: ${_step.next_flow[0]}` }

View File

@@ -143,14 +143,9 @@ class DialogCalendarEventDetail extends LitElement {
this.hass.locale.time_zone,
this.hass.config.time_zone
);
// For all-day events (date-only strings), parse without timezone to avoid offset issues
const start = isDate(this._data!.dtstart)
? new Date(this._data!.dtstart + "T00:00:00")
: new TZDate(this._data!.dtstart, timeZone);
const endValue = isDate(this._data!.dtend)
? new Date(this._data!.dtend + "T00:00:00")
: new TZDate(this._data!.dtend, timeZone);
// All day event end dates are exclusive in iCalendar format, subtract one day for display
const start = new TZDate(this._data!.dtstart, timeZone);
const endValue = new TZDate(this._data!.dtend, timeZone);
// All day events should be displayed as a day earlier
const end = isDate(this._data.dtend) ? addDays(endValue, -1) : endValue;
// The range can be shortened when the start and end are on the same day.
if (isSameDay(start, end)) {

View File

@@ -19,7 +19,6 @@ import { ensureArray } from "../../../../../common/array/ensure-array";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { hasTemplate } from "../../../../../common/string/has-template";
import type { StateTrigger } from "../../../../../data/automation";
import { ANY_STATE_VALUE } from "../../../../../components/entity/const";
import type { HomeAssistant } from "../../../../../types";
import { baseTriggerStruct, forDictStruct } from "../../structs";
import type { TriggerElement } from "../ha-automation-trigger-row";
@@ -37,12 +36,14 @@ const stateTriggerStruct = assign(
trigger: literal("state"),
entity_id: optional(union([string(), array(string())])),
attribute: optional(string()),
from: optional(union([nullable(string()), array(string())])),
to: optional(union([nullable(string()), array(string())])),
from: optional(nullable(string())),
to: optional(nullable(string())),
for: optional(union([number(), string(), forDictStruct])),
})
);
const ANY_STATE_VALUE = "__ANY_STATE_IGNORE_ATTRIBUTES__";
@customElement("ha-automation-trigger-state")
export class HaStateTrigger extends LitElement implements TriggerElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -56,12 +57,7 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
}
private _schema = memoizeOne(
(
localize: LocalizeFunc,
attribute: string | undefined,
hideInFrom: string[],
hideInTo: string[]
) =>
(localize: LocalizeFunc, attribute) =>
[
{
name: "entity_id",
@@ -135,7 +131,6 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
},
selector: {
state: {
multiple: true,
extra_options: (attribute
? []
: [
@@ -147,7 +142,6 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
},
]) as any,
attribute: attribute,
hide_states: hideInFrom,
},
},
},
@@ -158,7 +152,6 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
},
selector: {
state: {
multiple: true,
extra_options: (attribute
? []
: [
@@ -170,7 +163,6 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
},
]) as any,
attribute: attribute,
hide_states: hideInTo,
},
},
},
@@ -215,15 +207,13 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
entity_id: ensureArray(this.trigger.entity_id),
for: trgFor,
};
data.to = this._normalizeStates(this.trigger.to, data.attribute);
data.from = this._normalizeStates(this.trigger.from, data.attribute);
const schema = this._schema(
this.hass.localize,
this.trigger.attribute,
data.to,
data.from
);
if (!data.attribute && data.to === null) {
data.to = ANY_STATE_VALUE;
}
if (!data.attribute && data.from === null) {
data.from = ANY_STATE_VALUE;
}
const schema = this._schema(this.hass.localize, this.trigger.attribute);
return html`
<ha-form
@@ -241,58 +231,22 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
ev.stopPropagation();
const newTrigger = ev.detail.value;
newTrigger.to = this._applyAnyStateExclusive(
newTrigger.to,
newTrigger.attribute
);
newTrigger.from = this._applyAnyStateExclusive(
newTrigger.from,
newTrigger.attribute
);
if (newTrigger.to === ANY_STATE_VALUE) {
newTrigger.to = newTrigger.attribute ? undefined : null;
}
if (newTrigger.from === ANY_STATE_VALUE) {
newTrigger.from = newTrigger.attribute ? undefined : null;
}
Object.keys(newTrigger).forEach((key) => {
const val = newTrigger[key];
if (
val === undefined ||
val === "" ||
(Array.isArray(val) && val.length === 0)
) {
delete newTrigger[key];
}
});
Object.keys(newTrigger).forEach((key) =>
newTrigger[key] === undefined || newTrigger[key] === ""
? delete newTrigger[key]
: {}
);
fireEvent(this, "value-changed", { value: newTrigger });
}
private _applyAnyStateExclusive(
val: string | string[] | null | undefined,
attribute?: string
): string | string[] | null | undefined {
const anyStateSelected = Array.isArray(val)
? val.includes(ANY_STATE_VALUE)
: val === ANY_STATE_VALUE;
if (anyStateSelected) {
// Any state is exclusive: null if no attribute, undefined if attribute
return attribute ? undefined : null;
}
return val;
}
private _normalizeStates(
value: string | string[] | null | undefined,
attribute?: string
): string[] {
// If no attribute is selected and backend value is null,
// expose it as the special ANY state option in the UI.
if (!attribute && value === null) {
return [ANY_STATE_VALUE];
}
if (value === undefined || value === null) {
return [];
}
return ensureArray(value);
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string =>

View File

@@ -280,11 +280,10 @@ ${type === "object"
.content.horizontal {
--code-mirror-max-height: calc(
100vh - var(--header-height) -
(var(--ha-line-height-normal) * var(--ha-font-size-m) * 3) -
(max(16px, var(--safe-area-inset-top)) * 2) -
100vh - var(--header-height) - (var(--ha-line-height-normal) * 3) -
(1em * 2) - (max(16px, var(--safe-area-inset-top)) * 2) -
(max(16px, var(--safe-area-inset-bottom)) * 2) -
(var(--ha-card-border-width, 1px) * 3) - (1em * 2) - 192px
(var(--ha-card-border-width, 1px) * 2) - 179px
);
}

View File

@@ -313,14 +313,9 @@ class HaPanelHistory extends LitElement {
return;
}
const statsStartDate = new Date(this._startDate);
// History uses the end datapoint of the statistic, so if we want the
// graph to start at 7AM, need to fetch the statistic from 6AM.
statsStartDate.setHours(statsStartDate.getHours() - 1);
const statistics = await fetchStatistics(
this.hass!,
statsStartDate,
this._startDate,
this._endDate,
statisticIds,
"hour",

View File

@@ -1,126 +0,0 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeDomain } from "../../../common/entity/compute_domain";
import { supportsFeature } from "../../../common/entity/supports-feature";
import "../../../components/ha-control-number-buttons";
import { isUnavailableState } from "../../../data/entity";
import {
MediaPlayerEntityFeature,
type MediaPlayerEntity,
} from "../../../data/media-player";
import type { HomeAssistant } from "../../../types";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import { cardFeatureStyles } from "./common/card-feature-styles";
import type {
LovelaceCardFeatureContext,
MediaPlayerVolumeButtonsCardFeatureConfig,
} from "./types";
import { clamp } from "../../../common/number/clamp";
export const supportsMediaPlayerVolumeButtonsCardFeature = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext
) => {
const stateObj = context.entity_id
? hass.states[context.entity_id]
: undefined;
if (!stateObj) return false;
const domain = computeDomain(stateObj.entity_id);
return (
domain === "media_player" &&
supportsFeature(stateObj, MediaPlayerEntityFeature.VOLUME_SET)
);
};
@customElement("hui-media-player-volume-buttons-card-feature")
class HuiMediaPlayerVolumeButtonsCardFeature
extends LitElement
implements LovelaceCardFeature
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: MediaPlayerVolumeButtonsCardFeatureConfig;
private get _stateObj() {
if (!this.hass || !this.context || !this.context.entity_id) {
return undefined;
}
return this.hass.states[this.context.entity_id] as
| MediaPlayerEntity
| undefined;
}
static getStubConfig(): MediaPlayerVolumeButtonsCardFeatureConfig {
return {
type: "media-player-volume-buttons",
step: 5,
};
}
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
await import(
"../editor/config-elements/hui-media-player-volume-buttons-card-feature-editor"
);
return document.createElement(
"hui-media-player-volume-buttons-card-feature-editor"
);
}
public setConfig(config: MediaPlayerVolumeButtonsCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
protected render() {
if (
!this._config ||
!this.hass ||
!this.context ||
!this._stateObj ||
!supportsMediaPlayerVolumeButtonsCardFeature(this.hass, this.context)
) {
return nothing;
}
const position =
this._stateObj.attributes.volume_level != null
? Math.round(this._stateObj.attributes.volume_level * 100)
: undefined;
return html`
<ha-control-number-buttons
.disabled=${!this._stateObj || isUnavailableState(this._stateObj.state)}
.locale=${this.hass.locale}
min="0"
max="100"
.step=${this._config.step ?? 5}
.value=${position}
unit="%"
@value-changed=${this._valueChanged}
></ha-control-number-buttons>
`;
}
private _valueChanged(ev: CustomEvent) {
ev.stopPropagation();
this.hass!.callService("media_player", "volume_set", {
entity_id: this._stateObj!.entity_id,
volume_level: clamp(ev.detail.value, 0, 100) / 100,
});
}
static get styles() {
return cardFeatureStyles;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-media-player-volume-buttons-card-feature": HuiMediaPlayerVolumeButtonsCardFeature;
}
}

View File

@@ -50,11 +50,6 @@ export interface MediaPlayerVolumeSliderCardFeatureConfig {
type: "media-player-volume-slider";
}
export interface MediaPlayerVolumeButtonsCardFeatureConfig {
type: "media-player-volume-buttons";
step?: number;
}
export interface FanDirectionCardFeatureConfig {
type: "fan-direction";
}
@@ -257,7 +252,6 @@ export type LovelaceCardFeatureConfig =
| LockCommandsCardFeatureConfig
| LockOpenDoorCardFeatureConfig
| MediaPlayerPlaybackCardFeatureConfig
| MediaPlayerVolumeButtonsCardFeatureConfig
| MediaPlayerVolumeSliderCardFeatureConfig
| NumericInputCardFeatureConfig
| SelectOptionsCardFeatureConfig

View File

@@ -1,6 +1,6 @@
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, state } from "lit/decorators";
import { DOMAINS_TOGGLE } from "../../../common/const";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { computeDomain } from "../../../common/entity/compute_domain";
@@ -20,11 +20,9 @@ import type {
import type {
LovelaceCard,
LovelaceCardEditor,
LovelaceGridOptions,
LovelaceHeaderFooter,
} from "../types";
import type { EntitiesCardConfig } from "./types";
import { haStyleScrollbar } from "../../../resources/styles";
export const computeShowHeaderToggle = <
T extends EntityConfig | LovelaceRowConfig,
@@ -77,8 +75,6 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
private _hass?: HomeAssistant;
@property({ attribute: false }) public layout?: string;
private _configEntities?: LovelaceRowConfig[];
private _showHeaderToggle?: boolean;
@@ -143,14 +139,6 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
return size;
}
public getGridOptions(): LovelaceGridOptions {
return {
columns: 12,
min_columns: 6,
min_rows: this._config?.title || this._showHeaderToggle ? 3 : 2,
};
}
public setConfig(config: EntitiesCardConfig): void {
if (!config.entities || !Array.isArray(config.entities)) {
throw new Error("Entities must be specified");
@@ -245,7 +233,7 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
`}
</h1>
`}
<div id="states" class="card-content ha-scrollbar">
<div id="states" class="card-content">
${this._configEntities!.map((entityConf) =>
this._renderEntity(entityConf)
)}
@@ -258,73 +246,69 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
`;
}
static styles = [
haStyleScrollbar,
css`
ha-card {
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.card-header {
display: flex;
justify-content: space-between;
}
static styles = css`
ha-card {
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.card-header {
display: flex;
justify-content: space-between;
}
.card-header .name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-header .name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#states {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--entities-card-row-gap, var(--card-row-gap, 8px));
overflow-y: auto;
}
#states {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--entities-card-row-gap, var(--card-row-gap, 8px));
}
#states > div > * {
overflow: clip visible;
}
#states > div > * {
overflow: clip visible;
}
#states > div {
position: relative;
}
#states > div {
position: relative;
}
.icon {
padding: 0px 18px 0px 8px;
}
.icon {
padding: 0px 18px 0px 8px;
}
.header {
border-top-left-radius: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
);
border-top-right-radius: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
);
margin-bottom: 16px;
overflow: hidden;
}
.header {
border-top-left-radius: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
);
border-top-right-radius: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
);
margin-bottom: 16px;
overflow: hidden;
}
.footer {
border-bottom-left-radius: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
);
border-bottom-right-radius: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
);
margin-top: -16px;
overflow: hidden;
}
`,
];
.footer {
border-bottom-left-radius: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
);
border-bottom-right-radius: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
);
margin-top: -16px;
overflow: hidden;
}
`;
private _renderEntity(entityConf: LovelaceRowConfig): TemplateResult {
const element = createRowElement(

View File

@@ -162,7 +162,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
private async _fetchStatistics(sensorNumericDeviceClasses: string[]) {
const now = new Date();
const start = new Date();
start.setHours(start.getHours() - this._hoursToShow - 1);
start.setHours(start.getHours() - this._hoursToShow);
const statistics = await fetchStatistics(
this.hass!,

View File

@@ -14,11 +14,7 @@ import { findEntities } from "../common/find-entities";
import { processConfigEntities } from "../common/process-config-entities";
import "../components/hui-warning";
import type { EntityConfig } from "../entity-rows/types";
import type {
LovelaceCard,
LovelaceCardEditor,
LovelaceGridOptions,
} from "../types";
import type { LovelaceCard, LovelaceCardEditor } from "../types";
import type { LogbookCardConfig } from "./types";
import { resolveEntityIDs } from "../../../data/selector";
import { ensureArray } from "../../../common/array/ensure-array";
@@ -68,15 +64,6 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
return 9 + (this._config?.title ? 1 : 0);
}
public getGridOptions(): LovelaceGridOptions {
return {
rows: 6,
columns: 12,
min_columns: 6,
min_rows: this._config?.title ? 4 : 3,
};
}
public validateTarget(
config: LogbookCardConfig
): HassServiceTarget | undefined {
@@ -202,10 +189,6 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
>
<div class="content">
<ha-logbook
class=${classMap({
"is-grid": this.layout === "grid",
"is-panel": this.layout === "panel",
})}
.hass=${this.hass}
.time=${this._time}
.entityIds=${this._getEntityIds()}
@@ -229,7 +212,6 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
}
.content {
height: 100%;
padding: 0 16px 16px;
}
@@ -242,11 +224,6 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
display: block;
}
ha-logbook.is-grid,
ha-logbook.is-panel {
height: 100%;
}
:host([ispanel]) .content,
:host([ispanel]) ha-logbook {
height: 100%;

View File

@@ -23,7 +23,6 @@ import "../card-features/hui-light-color-temp-card-feature";
import "../card-features/hui-lock-commands-card-feature";
import "../card-features/hui-lock-open-door-card-feature";
import "../card-features/hui-media-player-playback-card-feature";
import "../card-features/hui-media-player-volume-buttons-card-feature";
import "../card-features/hui-media-player-volume-slider-card-feature";
import "../card-features/hui-numeric-input-card-feature";
import "../card-features/hui-select-options-card-feature";
@@ -73,7 +72,6 @@ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
"lock-commands",
"lock-open-door",
"media-player-playback",
"media-player-volume-buttons",
"media-player-volume-slider",
"numeric-input",
"select-options",

View File

@@ -48,7 +48,6 @@ import { supportsLightColorTempCardFeature } from "../../card-features/hui-light
import { supportsLockCommandsCardFeature } from "../../card-features/hui-lock-commands-card-feature";
import { supportsLockOpenDoorCardFeature } from "../../card-features/hui-lock-open-door-card-feature";
import { supportsMediaPlayerPlaybackCardFeature } from "../../card-features/hui-media-player-playback-card-feature";
import { supportsMediaPlayerVolumeButtonsCardFeature } from "../../card-features/hui-media-player-volume-buttons-card-feature";
import { supportsMediaPlayerVolumeSliderCardFeature } from "../../card-features/hui-media-player-volume-slider-card-feature";
import { supportsNumericInputCardFeature } from "../../card-features/hui-numeric-input-card-feature";
import { supportsSelectOptionsCardFeature } from "../../card-features/hui-select-options-card-feature";
@@ -103,7 +102,6 @@ const UI_FEATURE_TYPES = [
"lock-commands",
"lock-open-door",
"media-player-playback",
"media-player-volume-buttons",
"media-player-volume-slider",
"numeric-input",
"select-options",
@@ -133,7 +131,6 @@ const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
"fan-preset-modes",
"humidifier-modes",
"lawn-mower-commands",
"media-player-volume-buttons",
"numeric-input",
"select-options",
"trend-graph",
@@ -174,7 +171,6 @@ const SUPPORTS_FEATURE_TYPES: Record<
"lock-commands": supportsLockCommandsCardFeature,
"lock-open-door": supportsLockOpenDoorCardFeature,
"media-player-playback": supportsMediaPlayerPlaybackCardFeature,
"media-player-volume-buttons": supportsMediaPlayerVolumeButtonsCardFeature,
"media-player-volume-slider": supportsMediaPlayerVolumeSliderCardFeature,
"numeric-input": supportsNumericInputCardFeature,
"select-options": supportsSelectOptionsCardFeature,

View File

@@ -1,86 +0,0 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import type {
LovelaceCardFeatureContext,
MediaPlayerVolumeButtonsCardFeatureConfig,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
@customElement("hui-media-player-volume-buttons-card-feature-editor")
export class HuiMediaPlayerVolumeButtonsCardFeatureEditor
extends LitElement
implements LovelaceCardFeatureEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: MediaPlayerVolumeButtonsCardFeatureConfig;
public setConfig(config: MediaPlayerVolumeButtonsCardFeatureConfig): void {
this._config = config;
}
private _schema = memoizeOne(
() =>
[
{
name: "step",
selector: {
number: {
mode: "slider",
step: 1,
min: 1,
max: 100,
unit_of_measurement: "%",
},
},
},
] as const
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const data: MediaPlayerVolumeButtonsCardFeatureConfig = {
type: "media-player-volume-buttons",
step: this._config.step ?? 5,
};
const schema = this._schema();
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _valueChanged(ev: CustomEvent): void {
fireEvent(this, "config-changed", { config: ev.detail.value });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) =>
this.hass!.localize(
`ui.panel.lovelace.editor.features.types.media-player-volume-buttons.${schema.name}`
);
}
declare global {
interface HTMLElementTagNameMap {
"hui-media-player-volume-buttons-card-feature-editor": HuiMediaPlayerVolumeButtonsCardFeatureEditor;
}
}

View File

@@ -92,7 +92,6 @@ export class HomeMainViewStrategy extends ReactiveElement {
? floor.name
: hass.localize("ui.panel.lovelace.strategy.home.areas"),
heading_style: "title",
icon: floor.icon,
},
...cards,
],
@@ -216,9 +215,7 @@ export class HomeMainViewStrategy extends ReactiveElement {
column_span: maxColumns,
cards: [],
};
const weatherEntity = Object.keys(hass.states)
.filter(weatherFilter)
.sort()[0];
const weatherEntity = Object.keys(hass.states).find(weatherFilter);
if (weatherEntity) {
widgetSection.cards!.push(

View File

@@ -761,6 +761,9 @@
"no_match": "No matching languages found",
"no_languages": "No languages available"
},
"icon-picker": {
"no_match": "No matching icons found"
},
"tts-picker": {
"tts": "Text-to-speech",
"none": "None"
@@ -8154,10 +8157,6 @@
"media-player-playback": {
"label": "Media player playback controls"
},
"media-player-volume-buttons": {
"label": "Media player volume buttons",
"step": "Step size"
},
"media-player-volume-slider": {
"label": "Media player volume slider"
},

View File

@@ -1,71 +0,0 @@
import { expect, test } from "vitest";
import { TZDate } from "@date-fns/tz";
import { isDate } from "../../../src/common/string/is_date";
/**
* These tests verify that all-day event dates are correctly identified
* and can be distinguished from datetime strings. This is critical for
* proper date display in the calendar event detail dialog.
*/
test("isDate correctly identifies date-only strings", () => {
// Valid date-only strings (all-day events)
expect(isDate("2025-10-10")).toBe(true);
expect(isDate("2007-06-28")).toBe(true);
expect(isDate("2025-12-31")).toBe(true);
// DateTime strings should not be identified as dates
expect(isDate("2025-10-10T00:00:00")).toBe(false);
expect(isDate("2025-10-10T14:30:00")).toBe(false);
expect(isDate("2025-10-10T14:30:00Z")).toBe(false);
expect(isDate("2025-10-10T14:30:00+00:00")).toBe(false);
expect(isDate("2025-10-10T14:30:00-08:00")).toBe(false);
});
test("Date parsing for all-day events", () => {
// Verify that date-only strings can be parsed as local dates
const dateStr = "2025-10-10";
const parsed = new Date(dateStr + "T00:00:00");
expect(parsed.getFullYear()).toBe(2025);
expect(parsed.getMonth()).toBe(9); // October (0-indexed)
expect(parsed.getDate()).toBe(10);
});
test("Timed events respect timezone conversion", () => {
// Verify that datetime strings with timezone info are properly converted with TZDate
const datetimeStr = "2025-10-10T14:30:00-07:00"; // 2:30 PM Pacific time
const timeZone = "America/Los_Angeles"; // UTC-7 (PDT) in October
// This should NOT be identified as a date-only string
expect(isDate(datetimeStr)).toBe(false);
// Timed events should use TZDate which respects timezone
const tzDate = new TZDate(datetimeStr, timeZone);
// The date should be October 10, 2:30 PM in LA timezone
expect(tzDate.getFullYear()).toBe(2025);
expect(tzDate.getMonth()).toBe(9); // October (0-indexed)
expect(tzDate.getDate()).toBe(10);
expect(tzDate.getHours()).toBe(14);
expect(tzDate.getMinutes()).toBe(30);
});
test("Timed events display different day due to timezone offset", () => {
// An event at 1 AM UTC on October 10 should display as October 9 in Pacific time
const utcDatetimeStr = "2025-10-10T01:00:00Z";
const timeZone = "America/Los_Angeles"; // UTC-7 (PDT) in October
// This should NOT be identified as a date-only string
expect(isDate(utcDatetimeStr)).toBe(false);
// Parse the UTC datetime in Pacific timezone
const tzDate = new TZDate(utcDatetimeStr, timeZone);
// Due to the -7 hour offset, 1 AM UTC becomes 6 PM on the previous day in Pacific
expect(tzDate.getFullYear()).toBe(2025);
expect(tzDate.getMonth()).toBe(9); // October (0-indexed)
expect(tzDate.getDate()).toBe(9); // Previous day
expect(tzDate.getHours()).toBe(18); // 6 PM
expect(tzDate.getMinutes()).toBe(0);
});

View File

@@ -2277,12 +2277,12 @@ __metadata:
languageName: node
linkType: hard
"@lezer/highlight@npm:1.2.3, @lezer/highlight@npm:^1.0.0":
version: 1.2.3
resolution: "@lezer/highlight@npm:1.2.3"
"@lezer/highlight@npm:1.2.2, @lezer/highlight@npm:^1.0.0":
version: 1.2.2
resolution: "@lezer/highlight@npm:1.2.2"
dependencies:
"@lezer/common": "npm:^1.3.0"
checksum: 10/8f787d464f8a036f117a0b23e73ac034d224a57d72501c6559089098a28f127c9e495b90ac7d132acc86199e0b64d4c038f75f9293a37c7c61add52fa1acdb4e
checksum: 10/73cb339de042b354cbc0b9e83978a91d2448435edae865a192cfc50d536e0b7d2e3cd563aabeb59eb6c86b0c38b3edc6f2871da8482c5dd8dca4a0899e743f7f
languageName: node
linkType: hard
@@ -9235,7 +9235,7 @@ __metadata:
"@fullcalendar/luxon3": "npm:6.1.19"
"@fullcalendar/timegrid": "npm:6.1.19"
"@home-assistant/webawesome": "npm:3.0.0-beta.6.ha.6"
"@lezer/highlight": "npm:1.2.3"
"@lezer/highlight": "npm:1.2.2"
"@lit-labs/motion": "npm:1.0.9"
"@lit-labs/observers": "npm:2.0.6"
"@lit-labs/virtualizer": "npm:2.1.1"