91 lines
3.1 KiB
TypeScript
91 lines
3.1 KiB
TypeScript
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
|
|
|
import { hasPopOutAnimationEnded } from '../../utils/animation.js';
|
|
import { dispatchDismissNotificationEvent } from '../../utils/notification.js';
|
|
|
|
// Manages the popup notification's modal interaction: dismiss on outside
|
|
// interaction or Escape, hold focus while it is shown, and emit the dismiss
|
|
// event once the pop-out animation finishes.
|
|
export class NotificationPopupController implements ReactiveController {
|
|
private _host: ReactiveControllerHost & HTMLElement;
|
|
private _getNotificationElement: () => HTMLElement | null;
|
|
private _elementFocusedBeforePopup: Element | null = null;
|
|
private _hasTakenFocus = false;
|
|
|
|
constructor(
|
|
host: ReactiveControllerHost & HTMLElement,
|
|
getNotificationElement: () => HTMLElement | null,
|
|
) {
|
|
this._host = host;
|
|
this._getNotificationElement = getNotificationElement;
|
|
host.addController(this);
|
|
}
|
|
|
|
public hostConnected(): void {
|
|
this._elementFocusedBeforePopup = document.activeElement;
|
|
|
|
window.addEventListener('click', this._handleOutsideInteraction);
|
|
window.addEventListener('focusin', this._handleOutsideInteraction);
|
|
|
|
// Escape is claimed in the capture phase: the popup is a modal surface and
|
|
// must consume Escape before non-modal background controls (e.g. the call
|
|
// controls) that also listen on `window`.
|
|
window.addEventListener('keydown', this._handleKeyDown, { capture: true });
|
|
}
|
|
|
|
public hostUpdated(): void {
|
|
const notification = this._getNotificationElement();
|
|
|
|
if (notification && !this._hasTakenFocus) {
|
|
this._hasTakenFocus = true;
|
|
notification.focus();
|
|
}
|
|
}
|
|
|
|
public hostDisconnected(): void {
|
|
window.removeEventListener('click', this._handleOutsideInteraction);
|
|
window.removeEventListener('focusin', this._handleOutsideInteraction);
|
|
window.removeEventListener('keydown', this._handleKeyDown, { capture: true });
|
|
|
|
this._hasTakenFocus = false;
|
|
|
|
// Focus returns to whatever held it before the popup appeared, unless
|
|
// something else has taken focus since.
|
|
if (
|
|
this._elementFocusedBeforePopup instanceof HTMLElement &&
|
|
document.activeElement === document.body
|
|
) {
|
|
this._elementFocusedBeforePopup.focus();
|
|
}
|
|
this._elementFocusedBeforePopup = null;
|
|
}
|
|
|
|
public dismiss = (): void => {
|
|
this._getNotificationElement()?.classList.add('exiting');
|
|
};
|
|
|
|
public handleAnimationEnd = (ev: AnimationEvent): void => {
|
|
if (hasPopOutAnimationEnded(ev)) {
|
|
dispatchDismissNotificationEvent(this._host);
|
|
}
|
|
};
|
|
|
|
private _handleOutsideInteraction = (ev: Event): void => {
|
|
if (!ev.composedPath().includes(this._host)) {
|
|
this.dismiss();
|
|
}
|
|
};
|
|
|
|
private _handleKeyDown = (ev: KeyboardEvent): void => {
|
|
if (ev.key === 'Escape') {
|
|
this.dismiss();
|
|
|
|
// `stopImmediatePropagation()` (not `stopPropagation()`) is required to
|
|
// block sibling `window` listeners -- `stopPropagation()` only stops
|
|
// propagation to other targets, not other listeners on `window` itself.
|
|
ev.stopImmediatePropagation();
|
|
ev.preventDefault();
|
|
}
|
|
};
|
|
}
|