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
@@ -82,6 +82,43 @@ describe('handleControlAction', () => {
expect(onDismiss).not.toHaveBeenCalled();
});
it.each([['hold'], ['double_tap']])(
'should dismiss the notification on a %s',
(interaction: string) => {
vi.mocked(getActionConfigGivenAction).mockReturnValue(null);
const ev = new CustomEvent('action', { detail: { action: interaction } });
const host = document.createElement('div');
const onDismiss = vi.fn();
handleControlAction(ev, createControl(), host, onDismiss);
expect(onDismiss).toHaveBeenCalled();
},
);
it.each([['start_tap'], ['end_tap']])(
'should dispatch a %s action without dismissing',
(interaction: string) => {
const action = { action: 'navigate' as const, navigation_path: '/foo' };
vi.mocked(getActionConfigGivenAction).mockReturnValue(action);
const ev = new CustomEvent('action', { detail: { action: interaction } });
const control = createControl({
actions: { [`${interaction}_action`]: action },
});
const host = document.createElement('div');
const onDismiss = vi.fn();
handleControlAction(ev, control, host, onDismiss);
expect(dispatchActionExecutionRequest).toHaveBeenCalledWith(host, {
actions: [action],
});
expect(onDismiss).not.toHaveBeenCalled();
},
);
it('should not call onDismiss when no onDismiss is provided', () => {
vi.mocked(getActionConfigGivenAction).mockReturnValue(null);
@@ -0,0 +1,187 @@
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
onTestFinished,
vi,
} from 'vitest';
import { NotificationPopupViewportController } from '../../../src/components-lib/notification/notification-popup-viewport-controller';
import {
callResizeHandler,
createLitElement,
getResizeObserver,
ResizeObserverMock,
} from '../../test-utils';
// @vitest-environment jsdom
describe('NotificationPopupViewportController', () => {
beforeAll(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
});
afterAll(() => {
vi.unstubAllGlobals();
});
beforeEach(() => {
vi.clearAllMocks();
document.body.replaceChildren();
});
const create = (options?: { containerTop?: number; containerHeight?: number }) => {
const container = document.createElement('div');
document.body.appendChild(container);
const containerTop = options?.containerTop ?? 0;
const containerHeight = options?.containerHeight ?? 100;
const setContainerBox = (top: number, height: number): void => {
container.getBoundingClientRect = vi.fn().mockReturnValue({
top: top,
bottom: top + height,
});
};
setContainerBox(containerTop, containerHeight);
const host = createLitElement();
container.attachShadow({ mode: 'open' }).appendChild(host);
const controller = new NotificationPopupViewportController(host);
// Cleanup (disconnecting the window listeners) is registered per test, so
// leaked listeners cannot bleed into later tests.
onTestFinished(() => controller.hostDisconnected());
return { container, host, controller, setContainerBox };
};
const getInsets = (host: HTMLElement): { top: string; bottom: string } => ({
top: host.style.getPropertyValue('--notification-popup-inset-top'),
bottom: host.style.getPropertyValue('--notification-popup-inset-bottom'),
});
it('should add itself to the host', () => {
const { host, controller } = create();
expect(host.addController).toHaveBeenCalledWith(controller);
});
it('should not inset a container that fits within the viewport', () => {
const { host, controller } = create({ containerTop: 0, containerHeight: 100 });
controller.hostConnected();
expect(getInsets(host)).toEqual({ top: '0px', bottom: '0px' });
});
it('should inset the part of the container below the bottom of the viewport', () => {
const { host, controller } = create({
containerTop: 0,
containerHeight: window.innerHeight + 200,
});
controller.hostConnected();
expect(getInsets(host)).toEqual({ top: '0px', bottom: '200px' });
});
it('should inset the part of the container above the top of the viewport', () => {
const { host, controller } = create({
containerTop: -100,
containerHeight: window.innerHeight + 300,
});
controller.hostConnected();
expect(getInsets(host)).toEqual({ top: '100px', bottom: '200px' });
});
it('should keep the last inset when the container is entirely out of view', () => {
const { host, controller, setContainerBox } = create({
containerTop: 0,
containerHeight: window.innerHeight + 200,
});
controller.hostConnected();
setContainerBox(window.innerHeight, 100);
document.body.dispatchEvent(new Event('scroll'));
expect(getInsets(host)).toEqual({ top: '0px', bottom: '200px' });
});
it('should do nothing when the popup is not within a shadow root', () => {
const host = createLitElement();
document.body.appendChild(host);
const controller = new NotificationPopupViewportController(host);
controller.hostConnected();
onTestFinished(() => controller.hostDisconnected());
expect(getResizeObserver()?.observe).not.toHaveBeenCalled();
expect(getInsets(host)).toEqual({ top: '', bottom: '' });
});
describe('while connected', () => {
it('should recalculate the inset on scroll', () => {
const { host, controller, setContainerBox } = create();
controller.hostConnected();
setContainerBox(0, window.innerHeight + 50);
document.body.dispatchEvent(new Event('scroll'));
expect(getInsets(host)).toEqual({ top: '0px', bottom: '50px' });
});
it('should recalculate the inset on a viewport resize', () => {
const { host, controller, setContainerBox } = create();
controller.hostConnected();
setContainerBox(0, window.innerHeight + 50);
window.dispatchEvent(new Event('resize'));
expect(getInsets(host)).toEqual({ top: '0px', bottom: '50px' });
});
it('should recalculate the inset on a container resize', () => {
const { container, host, controller, setContainerBox } = create();
controller.hostConnected();
expect(getResizeObserver()?.observe).toHaveBeenCalledWith(container);
setContainerBox(0, window.innerHeight + 50);
callResizeHandler();
expect(getInsets(host)).toEqual({ top: '0px', bottom: '50px' });
});
});
describe('once disconnected', () => {
it('should not recalculate the inset on scroll', () => {
const { host, controller, setContainerBox } = create();
controller.hostConnected();
controller.hostDisconnected();
setContainerBox(0, window.innerHeight + 50);
document.body.dispatchEvent(new Event('scroll'));
expect(getInsets(host)).toEqual({ top: '0px', bottom: '0px' });
});
it('should not recalculate the inset on a viewport resize', () => {
const { host, controller, setContainerBox } = create();
controller.hostConnected();
controller.hostDisconnected();
setContainerBox(0, window.innerHeight + 50);
window.dispatchEvent(new Event('resize'));
expect(getInsets(host)).toEqual({ top: '0px', bottom: '0px' });
});
it('should stop observing container resizes', () => {
const { controller } = create();
controller.hostConnected();
controller.hostDisconnected();
expect(getResizeObserver()?.disconnect).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest';
import { deepQuery } from '../../browser/dom';
import { MountedCardFactory, type MountedCard } from '../../browser/mounted-card';
import {
createGenericCameraHASS,
createStillImageCardConfig,
} from '../../browser/test-utils';
const generateNotification = (card: MountedCard): void => {
card.card.dispatchEvent(
new CustomEvent('ll-custom', {
bubbles: true,
composed: true,
detail: {
action: 'fire-dom-event',
advanced_camera_card_action: 'notification',
notification: {
heading: { text: 'Heading', icon: 'mdi:information' },
},
},
}),
);
};
const isWithinViewport = (rect: DOMRect): boolean =>
rect.height > 0 && rect.top >= 0 && rect.bottom <= window.innerHeight;
// The `.notification` box inside the popup, but only once it is within the
// viewport, so a test can wait for the placement it asserts on.
const findVisibleNotification = (card: MountedCard): Element | null => {
const notification = deepQuery(card.card, 'advanced-camera-card-notification');
const box = notification ? deepQuery(notification, '.notification') : null;
return box && isWithinViewport(box.getBoundingClientRect()) ? box : null;
};
describe('AdvancedCameraCardNotification', () => {
it('should place the popup within the viewport on a card taller than it', async () => {
const card = await MountedCardFactory.createFromSource(
createStillImageCardConfig({
dimensions: { height: `${window.innerHeight * 3}px` },
}),
createGenericCameraHASS(),
);
generateNotification(card);
const box = await card.waitForRender(
() => findVisibleNotification(card),
'notification within the viewport',
);
expect(card.card.getBoundingClientRect().bottom).toBeGreaterThan(window.innerHeight);
expect(isWithinViewport(box.getBoundingClientRect())).toBe(true);
});
it('should center the popup within the visible part of the card', async () => {
const card = await MountedCardFactory.createFromSource(
createStillImageCardConfig({
dimensions: { height: `${window.innerHeight * 3}px` },
}),
createGenericCameraHASS(),
);
generateNotification(card);
const box = await card.waitForRender(
() => findVisibleNotification(card),
'notification within the viewport',
);
// The pop-in animation translates the popup while it runs; measure only
// once every animation on the popup has finished.
await Promise.all(box.getAnimations().map((animation) => animation.finished));
// The visible band runs from the card's top edge to the viewport bottom,
// and the popup's center should sit at the band's center.
const bandTop = card.card.getBoundingClientRect().top;
const bandCenter = (bandTop + window.innerHeight) / 2;
const rect = box.getBoundingClientRect();
const popupCenter = (rect.top + rect.bottom) / 2;
expect(Math.abs(popupCenter - bandCenter)).toBeLessThanOrEqual(2);
});
it('should not shrink the popup below its close control', async () => {
const card = await MountedCardFactory.createFromSource(
createStillImageCardConfig({
dimensions: { height: `${window.innerHeight}px` },
}),
createGenericCameraHASS(),
// Leave a sliver of the card on screen.
{ position: { top: `${window.innerHeight}px` } },
);
generateNotification(card);
// Bring 20 pixels of the card's top edge into view at the bottom of the
// viewport: far less room than the popup's minimum height.
window.scrollTo(0, 20);
await card.waitForRender(() => {
const notification = deepQuery(card.card, 'advanced-camera-card-notification');
const box = notification ? deepQuery(notification, '.notification') : null;
const rect = box?.getBoundingClientRect();
return rect &&
rect.height >= 48 &&
rect.top < window.innerHeight &&
rect.bottom > 0
? box
: null;
}, 'a popup no smaller than its close control');
});
it('should keep the popup within the viewport while scrolling', async () => {
const card = await MountedCardFactory.createFromSource(
createStillImageCardConfig({
dimensions: { height: `${window.innerHeight * 3}px` },
}),
createGenericCameraHASS(),
);
generateNotification(card);
await card.waitForRender(
() => findVisibleNotification(card),
'notification within the viewport',
);
// Scroll the middle of the card into view, putting both its top and bottom
// edges outside the viewport.
window.scrollTo(0, window.innerHeight);
const box = await card.waitForRender(
() => findVisibleNotification(card),
'notification within the viewport after scrolling',
);
expect(isWithinViewport(box.getBoundingClientRect())).toBe(true);
});
});
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { getShadowRootHost } from '../../src/utils/shadow-root';
// @vitest-environment jsdom
describe('getShadowRootHost', () => {
it('should return the host of the shadow root the element lives in', () => {
const host = document.createElement('div');
host.attachShadow({ mode: 'open' });
const element = document.createElement('span');
host.shadowRoot?.append(element);
expect(getShadowRootHost(element)).toBe(host);
});
it('should return null for an element in the light DOM', () => {
const element = document.createElement('span');
document.body.append(element);
try {
expect(getShadowRootHost(element)).toBeNull();
} finally {
element.remove();
}
});
it('should return null for an element that is not connected', () => {
expect(getShadowRootHost(document.createElement('span'))).toBeNull();
});
});