fix: Keep the notification popup visible / centered (#2702)

- Closes: #2693
This commit is contained in:
Dermot Duffy
2026-08-22 08:36:42 -07:00
committed by GitHub
parent 2f74889bcb
commit 0aebf3c484
10 changed files with 498 additions and 9 deletions
+7 -1
View File
@@ -6,6 +6,8 @@ import {
} from '../../utils/action.js';
import { arrayify } from '../../utils/basic.js';
const DISMISSING_INTERACTIONS = ['tap', 'hold', 'double_tap'];
export function handleControlAction(
ev: CustomEvent<{ action: string }>,
control: NotificationControl,
@@ -17,7 +19,11 @@ export function handleControlAction(
if (action) {
dispatchActionExecutionRequest(host, { actions: arrayify(action) });
}
if (onDismiss && control.dismiss !== false) {
if (
onDismiss &&
control.dismiss !== false &&
DISMISSING_INTERACTIONS.includes(ev.detail.action)
) {
onDismiss();
}
}
@@ -0,0 +1,69 @@
import type { ReactiveController, ReactiveControllerHost } from 'lit';
import { getShadowRootHost } from '../../utils/shadow-root.js';
const INSET_TOP_PROPERTY = '--notification-popup-inset-top';
const INSET_BOTTOM_PROPERTY = '--notification-popup-inset-bottom';
// Ensure the popup stays within the visible part of the card.
export class NotificationPopupViewportController implements ReactiveController {
private _host: ReactiveControllerHost & HTMLElement;
private _resizeObserver = new ResizeObserver(() => this._update());
constructor(host: ReactiveControllerHost & HTMLElement) {
this._host = host;
host.addController(this);
}
public hostConnected(): void {
const container = this._getContainer();
if (container) {
this._resizeObserver.observe(container);
}
// Scroll events do not bubble, so the listener uses the capture phase to
// observe scrolling in any element between the window and the container. A
// Home Assistant dashboard scrolls an element within the page rather than
// the window itself.
window.addEventListener('scroll', this._update, { capture: true, passive: true });
window.addEventListener('resize', this._update);
this._update();
}
public hostDisconnected(): void {
this._resizeObserver.disconnect();
window.removeEventListener('scroll', this._update, { capture: true });
window.removeEventListener('resize', this._update);
}
private _getContainer(): Element | null {
return getShadowRootHost(this._host);
}
private _update = (): void => {
const container = this._getContainer();
if (!container) {
return;
}
const containerBox = container.getBoundingClientRect();
const visibleTop = Math.max(containerBox.top, 0);
const visibleBottom = Math.min(containerBox.bottom, window.innerHeight);
if (visibleBottom <= visibleTop) {
// No inset can bring the popup on screen when the container itself is
// off screen, so the controller keeps the previous inset.
return;
}
this._host.style.setProperty(
INSET_TOP_PROPERTY,
`${visibleTop - containerBox.top}px`,
);
this._host.style.setProperty(
INSET_BOTTOM_PROPERTY,
`${containerBox.bottom - visibleBottom}px`,
);
};
}
+8
View File
@@ -11,6 +11,7 @@ import { createRef, ref, type Ref } from 'lit/directives/ref.js';
import { handleControlAction } from '../../components-lib/notification/action.js';
import { NotificationContextController } from '../../components-lib/notification/notification-context-controller.js';
import { NotificationPopupController } from '../../components-lib/notification/notification-popup-controller.js';
import { NotificationPopupViewportController } from '../../components-lib/notification/notification-popup-viewport-controller.js';
import type { Notification } from '../../config/schema/actions/types.js';
import { localize } from '../../localize/localize.js';
import notificationPopupStyle from '../../scss/notification-popup.scss?inline';
@@ -32,6 +33,13 @@ export class AdvancedCameraCardNotification extends LitElement {
);
private _contextController = new NotificationContextController(this);
constructor() {
super();
// Controller automatically registers itself with this element.
new NotificationPopupViewportController(this);
}
protected render(): TemplateResult | void {
if (!this.notification) {
return;
+3 -6
View File
@@ -1,11 +1,8 @@
import { getShadowRootHost } from '../utils/shadow-root';
/**
* Determine if a card is in panel mode.
*/
export const isCardInPanel = (card: HTMLElement): boolean => {
const parent = card.getRootNode();
return !!(
parent &&
parent instanceof ShadowRoot &&
parent.host.tagName === 'HUI-PANEL-VIEW'
);
return getShadowRootHost(card)?.tagName === 'HUI-PANEL-VIEW';
};
+11 -2
View File
@@ -4,10 +4,13 @@
:host {
position: absolute;
inset: 0;
inset: var(--notification-popup-inset-top, 0) 0
var(--notification-popup-inset-bottom, 0) 0;
display: flex;
justify-content: center;
align-items: flex-end;
align-items: center;
padding: 24px;
pointer-events: none;
z-index: $z-index-notification;
@@ -28,6 +31,12 @@
width: min(92%, 400px);
max-height: calc(100% - 48px);
// min-height has priority over max-height: with the 40px vertical padding it
// holds the border box at 48px (matched to equal control's 8px offset + 32px
// + 8px).
min-height: 8px;
padding: 20px 24px;
padding-right: 48px;
overflow-y: auto;
+8
View File
@@ -0,0 +1,8 @@
/**
* Get the host of the shadow root an element lives in, or `null` when the
* element is not in a shadow tree (in the light DOM, or not connected).
*/
export const getShadowRootHost = (element: Node): Element | null => {
const root = element.getRootNode();
return root instanceof ShadowRoot ? root.host : null;
};