mirror of
https://github.com/home-assistant/frontend.git
synced 2025-11-05 09:00:11 +00:00
Compare commits
15 Commits
ha-wa-dial
...
copilot/al
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84c8420286 | ||
|
|
1361fc36bf | ||
|
|
505ef2bd11 | ||
|
|
c0cc66c1ab | ||
|
|
7cfbc521c7 | ||
|
|
e064ce56cc | ||
|
|
8d688aa3a9 | ||
|
|
d122483449 | ||
|
|
f17bbc3f79 | ||
|
|
c88f8fcce0 | ||
|
|
8efabde916 | ||
|
|
e821e1ec83 | ||
|
|
dc7516da94 | ||
|
|
a545a377a7 | ||
|
|
3634dbcbbf |
@@ -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 guideliness. 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 guidelines. 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 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 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 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 unreadable long.
|
||||
- Try to avoid user generated content in the title, this could make the title unreadably long.
|
||||
- If users become unsure, they read the description. Make sure this explains what will happen.
|
||||
- Strive for minimalism.
|
||||
|
||||
|
||||
@@ -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.2",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"@lit-labs/motion": "1.0.9",
|
||||
"@lit-labs/observers": "2.0.6",
|
||||
"@lit-labs/virtualizer": "2.1.1",
|
||||
|
||||
116
src/common/util/swipe-gesture-recognizer.ts
Normal file
116
src/common/util/swipe-gesture-recognizer.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
1
src/components/entity/const.ts
Normal file
1
src/components/entity/const.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const ANY_STATE_VALUE = "__ANY_STATE_IGNORE_ATTRIBUTES__";
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
@@ -57,6 +58,7 @@ export class HaEntityStatesPicker extends LitElement {
|
||||
|
||||
const value = this.value || [];
|
||||
const hide = [...(this.hideStates || []), ...value];
|
||||
const hideValue = value.includes(ANY_STATE_VALUE);
|
||||
|
||||
return html`
|
||||
${repeat(
|
||||
@@ -84,7 +86,7 @@ export class HaEntityStatesPicker extends LitElement {
|
||||
`
|
||||
)}
|
||||
<div>
|
||||
${this.disabled && value.length
|
||||
${(this.disabled && value.length) || hideValue
|
||||
? nothing
|
||||
: keyed(
|
||||
value.length,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "@home-assistant/webawesome/dist/components/drawer/drawer";
|
||||
import { css, html, LitElement, type PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { SwipeGestureRecognizer } from "../common/util/swipe-gesture-recognizer";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
|
||||
export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300;
|
||||
@@ -14,6 +15,12 @@ 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", {
|
||||
@@ -33,19 +40,132 @@ 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 class="body ha-scrollbar">
|
||||
<div id="body" 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`
|
||||
@@ -59,6 +179,8 @@ 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);
|
||||
@@ -90,6 +212,11 @@ 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)
|
||||
);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -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.start / 1000,
|
||||
lc: e.end / 1000,
|
||||
a: {},
|
||||
lu: e.start / 1000,
|
||||
lu: e.end / 1000,
|
||||
}));
|
||||
statsHistoryStates[key] = entityHistoryStates;
|
||||
});
|
||||
|
||||
@@ -484,7 +484,7 @@ class DataEntryFlowDialog extends LitElement {
|
||||
this._unsubDataEntryFlowProgress = undefined;
|
||||
}
|
||||
if (_step.next_flow[0] === "config_flow") {
|
||||
showConfigFlowDialog(this._params!.dialogParentElement!, {
|
||||
showConfigFlowDialog(this, {
|
||||
continueFlowId: _step.next_flow[1],
|
||||
carryOverDevices: this._devices(
|
||||
this._params!.flowConfig.showDevices,
|
||||
@@ -496,32 +496,23 @@ class DataEntryFlowDialog extends LitElement {
|
||||
});
|
||||
} else if (_step.next_flow[0] === "options_flow") {
|
||||
if (_step.type === "create_entry") {
|
||||
showOptionsFlowDialog(
|
||||
this._params!.dialogParentElement!,
|
||||
_step.result!,
|
||||
{
|
||||
continueFlowId: _step.next_flow[1],
|
||||
navigateToResult: this._params!.navigateToResult,
|
||||
dialogClosedCallback: this._params!.dialogClosedCallback,
|
||||
}
|
||||
);
|
||||
showOptionsFlowDialog(this, _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._params!.dialogParentElement!,
|
||||
_step.result!,
|
||||
_step.next_flow[0],
|
||||
{
|
||||
continueFlowId: _step.next_flow[1],
|
||||
navigateToResult: this._params!.navigateToResult,
|
||||
dialogClosedCallback: this._params!.dialogClosedCallback,
|
||||
}
|
||||
);
|
||||
showSubConfigFlowDialog(this, _step.result!, _step.next_flow[0], {
|
||||
continueFlowId: _step.next_flow[1],
|
||||
navigateToResult: this._params!.navigateToResult,
|
||||
dialogClosedCallback: this._params!.dialogClosedCallback,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.closeDialog();
|
||||
showAlertDialog(this._params!.dialogParentElement!, {
|
||||
showAlertDialog(this, {
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.integrations.config_flow.error",
|
||||
{ error: `Unsupported next flow type: ${_step.next_flow[0]}` }
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { mdiAlertOutline, mdiClose } from "@mdi/js";
|
||||
import { mdiAlertOutline } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-dialog-footer";
|
||||
import "../../components/ha-dialog-header";
|
||||
import "../../components/ha-md-dialog";
|
||||
import type { HaMdDialog } from "../../components/ha-md-dialog";
|
||||
import "../../components/ha-svg-icon";
|
||||
import "../../components/ha-textfield";
|
||||
import type { HaTextField } from "../../components/ha-textfield";
|
||||
import "../../components/ha-wa-dialog";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { DialogBoxParams } from "./show-dialog-box";
|
||||
|
||||
@@ -19,12 +19,12 @@ class DialogBox extends LitElement {
|
||||
|
||||
@state() private _params?: DialogBoxParams;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _closeState?: "canceled" | "confirmed";
|
||||
|
||||
@query("ha-textfield") private _textField?: HaTextField;
|
||||
|
||||
@query("ha-md-dialog") private _dialog?: HaMdDialog;
|
||||
|
||||
private _closePromise?: Promise<void>;
|
||||
|
||||
private _closeResolve?: () => void;
|
||||
@@ -34,7 +34,6 @@ class DialogBox extends LitElement {
|
||||
await this._closePromise;
|
||||
}
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
@@ -61,25 +60,16 @@ class DialogBox extends LitElement {
|
||||
this.hass.localize("ui.dialogs.generic.default_confirmation_title"));
|
||||
|
||||
return html`
|
||||
<ha-wa-dialog
|
||||
.hass=${this.hass}
|
||||
.open=${this._open}
|
||||
?prevent-scrim-close=${confirmPrompt}
|
||||
<ha-md-dialog
|
||||
open
|
||||
.disableCancelAction=${confirmPrompt}
|
||||
@closed=${this._dialogClosed}
|
||||
type="alert"
|
||||
aria-labelledby="dialog-box-title"
|
||||
aria-describedby="dialog-box-description"
|
||||
>
|
||||
<ha-dialog-header slot="header">
|
||||
${!confirmPrompt
|
||||
? html`<slot name="headerNavigationIcon" slot="navigationIcon">
|
||||
<ha-icon-button
|
||||
data-dialog="close"
|
||||
.label=${this.hass?.localize("ui.common.close") ?? "Close"}
|
||||
.path=${mdiClose}
|
||||
></ha-icon-button
|
||||
></slot>`
|
||||
: nothing}
|
||||
<span slot="title" id="dialog-box-title">
|
||||
<div slot="headline">
|
||||
<span .title=${dialogTitle} id="dialog-box-title">
|
||||
${this._params.warning
|
||||
? html`<ha-svg-icon
|
||||
.path=${mdiAlertOutline}
|
||||
@@ -88,13 +78,13 @@ class DialogBox extends LitElement {
|
||||
: nothing}
|
||||
${dialogTitle}
|
||||
</span>
|
||||
</ha-dialog-header>
|
||||
<div id="dialog-box-description">
|
||||
</div>
|
||||
<div slot="content" id="dialog-box-description">
|
||||
${this._params.text ? html` <p>${this._params.text}</p> ` : ""}
|
||||
${this._params.prompt
|
||||
? html`
|
||||
<ha-textfield
|
||||
autofocus
|
||||
dialogInitialFocus
|
||||
value=${ifDefined(this._params.defaultValue)}
|
||||
.placeholder=${this._params.placeholder}
|
||||
.label=${this._params.inputLabel
|
||||
@@ -109,11 +99,10 @@ class DialogBox extends LitElement {
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<div slot="actions">
|
||||
${confirmPrompt
|
||||
? html`
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
@click=${this._dismiss}
|
||||
?autofocus=${!this._params.prompt && this._params.destructive}
|
||||
appearance="plain"
|
||||
@@ -125,7 +114,6 @@ class DialogBox extends LitElement {
|
||||
`
|
||||
: nothing}
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._confirm}
|
||||
?autofocus=${!this._params.prompt && !this._params.destructive}
|
||||
variant=${this._params.destructive ? "danger" : "brand"}
|
||||
@@ -134,8 +122,8 @@ class DialogBox extends LitElement {
|
||||
? this._params.confirmText
|
||||
: this.hass.localize("ui.common.ok")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-wa-dialog>
|
||||
</div>
|
||||
</ha-md-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -160,7 +148,8 @@ class DialogBox extends LitElement {
|
||||
}
|
||||
|
||||
private _closeDialog() {
|
||||
this._open = false;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
this._dialog?.close();
|
||||
this._closePromise = new Promise((resolve) => {
|
||||
this._closeResolve = resolve;
|
||||
});
|
||||
@@ -173,7 +162,6 @@ class DialogBox extends LitElement {
|
||||
}
|
||||
this._closeState = undefined;
|
||||
this._params = undefined;
|
||||
this._open = false;
|
||||
this._closeResolve?.();
|
||||
this._closeResolve = undefined;
|
||||
}
|
||||
|
||||
@@ -143,9 +143,14 @@ class DialogCalendarEventDetail extends LitElement {
|
||||
this.hass.locale.time_zone,
|
||||
this.hass.config.time_zone
|
||||
);
|
||||
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
|
||||
// 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 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)) {
|
||||
|
||||
@@ -19,6 +19,7 @@ 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";
|
||||
@@ -36,14 +37,12 @@ const stateTriggerStruct = assign(
|
||||
trigger: literal("state"),
|
||||
entity_id: optional(union([string(), array(string())])),
|
||||
attribute: optional(string()),
|
||||
from: optional(nullable(string())),
|
||||
to: optional(nullable(string())),
|
||||
from: optional(union([nullable(string()), array(string())])),
|
||||
to: optional(union([nullable(string()), array(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;
|
||||
@@ -57,7 +56,12 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(localize: LocalizeFunc, attribute) =>
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
attribute: string | undefined,
|
||||
hideInFrom: string[],
|
||||
hideInTo: string[]
|
||||
) =>
|
||||
[
|
||||
{
|
||||
name: "entity_id",
|
||||
@@ -131,6 +135,7 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
|
||||
},
|
||||
selector: {
|
||||
state: {
|
||||
multiple: true,
|
||||
extra_options: (attribute
|
||||
? []
|
||||
: [
|
||||
@@ -142,6 +147,7 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
|
||||
},
|
||||
]) as any,
|
||||
attribute: attribute,
|
||||
hide_states: hideInFrom,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -152,6 +158,7 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
|
||||
},
|
||||
selector: {
|
||||
state: {
|
||||
multiple: true,
|
||||
extra_options: (attribute
|
||||
? []
|
||||
: [
|
||||
@@ -163,6 +170,7 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
|
||||
},
|
||||
]) as any,
|
||||
attribute: attribute,
|
||||
hide_states: hideInTo,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -207,13 +215,15 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
|
||||
entity_id: ensureArray(this.trigger.entity_id),
|
||||
for: trgFor,
|
||||
};
|
||||
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);
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
@@ -231,22 +241,58 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
|
||||
ev.stopPropagation();
|
||||
const newTrigger = ev.detail.value;
|
||||
|
||||
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) =>
|
||||
newTrigger[key] === undefined || newTrigger[key] === ""
|
||||
? delete newTrigger[key]
|
||||
: {}
|
||||
newTrigger.to = this._applyAnyStateExclusive(
|
||||
newTrigger.to,
|
||||
newTrigger.attribute
|
||||
);
|
||||
newTrigger.from = this._applyAnyStateExclusive(
|
||||
newTrigger.from,
|
||||
newTrigger.attribute
|
||||
);
|
||||
|
||||
Object.keys(newTrigger).forEach((key) => {
|
||||
const val = newTrigger[key];
|
||||
if (
|
||||
val === undefined ||
|
||||
val === "" ||
|
||||
(Array.isArray(val) && val.length === 0)
|
||||
) {
|
||||
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 =>
|
||||
|
||||
@@ -280,10 +280,11 @@ ${type === "object"
|
||||
|
||||
.content.horizontal {
|
||||
--code-mirror-max-height: calc(
|
||||
100vh - var(--header-height) - (var(--ha-line-height-normal) * 3) -
|
||||
(1em * 2) - (max(16px, var(--safe-area-inset-top)) * 2) -
|
||||
100vh - var(--header-height) -
|
||||
(var(--ha-line-height-normal) * var(--ha-font-size-m) * 3) -
|
||||
(max(16px, var(--safe-area-inset-top)) * 2) -
|
||||
(max(16px, var(--safe-area-inset-bottom)) * 2) -
|
||||
(var(--ha-card-border-width, 1px) * 2) - 179px
|
||||
(var(--ha-card-border-width, 1px) * 3) - (1em * 2) - 192px
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -313,9 +313,14 @@ 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!,
|
||||
this._startDate,
|
||||
statsStartDate,
|
||||
this._endDate,
|
||||
statisticIds,
|
||||
"hour",
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,11 @@ export interface MediaPlayerVolumeSliderCardFeatureConfig {
|
||||
type: "media-player-volume-slider";
|
||||
}
|
||||
|
||||
export interface MediaPlayerVolumeButtonsCardFeatureConfig {
|
||||
type: "media-player-volume-buttons";
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export interface FanDirectionCardFeatureConfig {
|
||||
type: "fan-direction";
|
||||
}
|
||||
@@ -252,6 +257,7 @@ export type LovelaceCardFeatureConfig =
|
||||
| LockCommandsCardFeatureConfig
|
||||
| LockOpenDoorCardFeatureConfig
|
||||
| MediaPlayerPlaybackCardFeatureConfig
|
||||
| MediaPlayerVolumeButtonsCardFeatureConfig
|
||||
| MediaPlayerVolumeSliderCardFeatureConfig
|
||||
| NumericInputCardFeatureConfig
|
||||
| SelectOptionsCardFeatureConfig
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { customElement, property, 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,9 +20,11 @@ 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,
|
||||
@@ -75,6 +77,8 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
|
||||
|
||||
private _hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public layout?: string;
|
||||
|
||||
private _configEntities?: LovelaceRowConfig[];
|
||||
|
||||
private _showHeaderToggle?: boolean;
|
||||
@@ -139,6 +143,14 @@ 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");
|
||||
@@ -233,7 +245,7 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
|
||||
`}
|
||||
</h1>
|
||||
`}
|
||||
<div id="states" class="card-content">
|
||||
<div id="states" class="card-content ha-scrollbar">
|
||||
${this._configEntities!.map((entityConf) =>
|
||||
this._renderEntity(entityConf)
|
||||
)}
|
||||
@@ -246,69 +258,73 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
.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));
|
||||
}
|
||||
#states {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--entities-card-row-gap, var(--card-row-gap, 8px));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#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(
|
||||
|
||||
@@ -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);
|
||||
start.setHours(start.getHours() - this._hoursToShow - 1);
|
||||
|
||||
const statistics = await fetchStatistics(
|
||||
this.hass!,
|
||||
|
||||
@@ -14,7 +14,11 @@ 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 } from "../types";
|
||||
import type {
|
||||
LovelaceCard,
|
||||
LovelaceCardEditor,
|
||||
LovelaceGridOptions,
|
||||
} from "../types";
|
||||
import type { LogbookCardConfig } from "./types";
|
||||
import { resolveEntityIDs } from "../../../data/selector";
|
||||
import { ensureArray } from "../../../common/array/ensure-array";
|
||||
@@ -64,6 +68,15 @@ 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 {
|
||||
@@ -189,6 +202,10 @@ 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()}
|
||||
@@ -212,6 +229,7 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
|
||||
.content {
|
||||
height: 100%;
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
@@ -224,6 +242,11 @@ 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%;
|
||||
|
||||
@@ -23,6 +23,7 @@ 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";
|
||||
@@ -72,6 +73,7 @@ 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",
|
||||
|
||||
@@ -48,6 +48,7 @@ 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";
|
||||
@@ -102,6 +103,7 @@ const UI_FEATURE_TYPES = [
|
||||
"lock-commands",
|
||||
"lock-open-door",
|
||||
"media-player-playback",
|
||||
"media-player-volume-buttons",
|
||||
"media-player-volume-slider",
|
||||
"numeric-input",
|
||||
"select-options",
|
||||
@@ -131,6 +133,7 @@ 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",
|
||||
@@ -171,6 +174,7 @@ 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,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,7 @@ export class HomeMainViewStrategy extends ReactiveElement {
|
||||
? floor.name
|
||||
: hass.localize("ui.panel.lovelace.strategy.home.areas"),
|
||||
heading_style: "title",
|
||||
icon: floor.icon,
|
||||
},
|
||||
...cards,
|
||||
],
|
||||
@@ -215,7 +216,9 @@ export class HomeMainViewStrategy extends ReactiveElement {
|
||||
column_span: maxColumns,
|
||||
cards: [],
|
||||
};
|
||||
const weatherEntity = Object.keys(hass.states).find(weatherFilter);
|
||||
const weatherEntity = Object.keys(hass.states)
|
||||
.filter(weatherFilter)
|
||||
.sort()[0];
|
||||
|
||||
if (weatherEntity) {
|
||||
widgetSection.cards!.push(
|
||||
|
||||
@@ -8154,6 +8154,10 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
71
test/panels/calendar/dialog-calendar-event-detail.test.ts
Normal file
71
test/panels/calendar/dialog-calendar-event-detail.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
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);
|
||||
});
|
||||
10
yarn.lock
10
yarn.lock
@@ -2277,12 +2277,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@lezer/highlight@npm:1.2.2, @lezer/highlight@npm:^1.0.0":
|
||||
version: 1.2.2
|
||||
resolution: "@lezer/highlight@npm:1.2.2"
|
||||
"@lezer/highlight@npm:1.2.3, @lezer/highlight@npm:^1.0.0":
|
||||
version: 1.2.3
|
||||
resolution: "@lezer/highlight@npm:1.2.3"
|
||||
dependencies:
|
||||
"@lezer/common": "npm:^1.3.0"
|
||||
checksum: 10/73cb339de042b354cbc0b9e83978a91d2448435edae865a192cfc50d536e0b7d2e3cd563aabeb59eb6c86b0c38b3edc6f2871da8482c5dd8dca4a0899e743f7f
|
||||
checksum: 10/8f787d464f8a036f117a0b23e73ac034d224a57d72501c6559089098a28f127c9e495b90ac7d132acc86199e0b64d4c038f75f9293a37c7c61add52fa1acdb4e
|
||||
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.2"
|
||||
"@lezer/highlight": "npm:1.2.3"
|
||||
"@lit-labs/motion": "npm:1.0.9"
|
||||
"@lit-labs/observers": "npm:2.0.6"
|
||||
"@lit-labs/virtualizer": "npm:2.1.1"
|
||||
|
||||
Reference in New Issue
Block a user