Files
houseplan-card/src/hp-dialog.ts
T
2026-09-05 17:53:16 +03:00

562 lines
18 KiB
TypeScript

import { LitElement, css, html, nothing, type PropertyValues } from 'lit';
type FocusSession = {
dialogs: Set<HpDialog>;
opener: HTMLElement | null;
};
export type HpOverlayCloseReason = 'escape' | 'exclusive' | 'outside' | 'scroll' | 'toast' | 'disconnect';
export type HpOverlayRegistration = {
owner: HTMLElement;
close: (reason: HpOverlayCloseReason) => void;
group?: 'transient' | string;
};
type OverlayEntry = HpOverlayRegistration & { token: symbol };
// A card can replace one dialog with another in the same Lit update (for
// example, device info -> device editor) or open a child dialog above a parent.
// Keep the original opener per render root so those transitions do not lose it.
const focusSessions = new WeakMap<Node, FocusSession>();
let dialogSequence = 0;
/**
* Shared modal shell for Houseplan.
*
* Home Assistant provides the visual surface and focus trap through ha-dialog
* for ordinary dialogs. Alert confirmations deliberately use the native branch:
* the pinned HA component exposes an alert type but does not forward its role
* to the actual dialog. Both branches expose the same `hp-close` contract and
* restore focus to the control that opened the dialog.
*/
export class HpDialog extends LitElement {
static properties = {
title: { type: String },
icon: { type: String },
wide: { type: Boolean, reflect: true },
alert: { type: Boolean, reflect: true },
describedBy: { type: String, attribute: 'described-by' },
dismissOnScrim: { type: Boolean, attribute: 'dismiss-on-scrim' },
hass: { attribute: false },
};
static styles = css`
:host {
display: contents;
color: var(--primary-text-color, #e6e7eb);
font: inherit;
}
ha-dialog {
--dialog-content-padding: 0;
--dialog-surface-background: var(--card-background-color, var(--hp-bg, #202126));
--ha-dialog-border-radius: var(--rad-l, 18px);
/* HA's ha-dialog-header defaults this custom property to a one-line
fixed height. Our localized slot is intentionally allowed to wrap,
so leaving that default in place clips every line after the first at
the bottom of the header. auto is HA's public sizing hook and also
stays harmless on older ha-dialog implementations that do not consume
it. */
--ha-dialog-header-title-height: auto;
color: inherit;
}
ha-dialog::part(dialog) {
border: 1px solid var(--hp-accent, #d89300);
box-shadow: var(--shadow-3, 0 18px 48px rgb(0 0 0 / 0.34));
overflow: hidden;
}
/* The HA header slot is a flex item with a constrained inline size. The
old inline-flex title kept its min-content width, so HA clipped the last
word instead of giving it a second line. Keep every wrapper shrinkable
and let the text wrap; this applies to every hp-dialog, including long
device names and translated titles. */
.header-title-slot {
display: block;
flex: 1 1 auto;
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
white-space: normal;
}
.title {
display: flex;
flex: 1 1 auto;
align-items: center;
gap: var(--sp-4, 12px);
width: 100%;
max-width: 100%;
min-width: 0;
font-weight: 600;
line-height: 1.25;
white-space: normal;
}
.title ha-icon {
flex: none;
color: var(--hp-accent, #d89300);
}
.title-text {
flex: 1 1 auto;
min-width: 0;
max-width: 100%;
white-space: normal;
overflow-wrap: anywhere;
word-break: normal;
}
.footer {
/* ha-dialog lays out its footer slot as a flex row. display: contents
exposed the consumer's action row as a shrink-to-fit flex item, so a
wide device dialog got a half-width divider and its Hide action slid
toward the centre. Keep one full-width slot item in both HA and the
native fallback. */
display: block;
width: 100%;
min-width: 0;
box-sizing: border-box;
}
::slotted([slot='footer']) {
width: 100%;
box-sizing: border-box;
}
dialog {
width: fit-content;
height: fit-content;
max-width: none;
max-height: none;
inset: 0;
margin: auto;
padding: 0;
border: 0;
overflow: visible;
color: inherit;
background: transparent;
}
dialog::backdrop {
background: rgb(0 0 0 / 0.45);
}
.surface {
width: min(360px, 92vw);
max-height: 92vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-sizing: border-box;
color: inherit;
background: var(--card-background-color, var(--hp-bg, #202126));
border: 1px solid var(--hp-accent, #d89300);
border-radius: var(--rad-l, 18px);
box-shadow: var(--shadow-3, 0 18px 48px rgb(0 0 0 / 0.34));
}
:host([wide]) .surface {
width: min(var(--hp-dialog-wide-width, 500px), 94vw);
}
.header {
min-height: 56px;
display: flex;
align-items: center;
gap: var(--sp-4, 12px);
padding: var(--sp-4, 12px) var(--sp-5, 16px);
box-sizing: border-box;
border-bottom: 1px solid var(--hp-line, rgb(255 255 255 / 0.12));
}
.header .title {
flex: 1;
}
.close {
width: 40px;
height: 40px;
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
color: inherit;
background: transparent;
border: 0;
border-radius: 50%;
cursor: pointer;
}
:host([data-pointer-hover]) .close:hover,
.close:focus-visible {
background: rgb(127 127 127 / 0.16);
}
.content {
min-height: 0;
display: flex;
flex-direction: column;
}
.overlay-portal {
position: fixed;
z-index: 2147483647;
inset: 0;
overflow: visible;
pointer-events: none;
}
.overlay-portal:empty {
display: none;
}
.overlay-portal > * {
pointer-events: auto;
}
`;
title = '';
icon = '';
wide = false;
alert = false;
describedBy = '';
dismissOnScrim = false;
hass: any = null;
private _opener: HTMLElement | null = null;
private _focusRoot: Node | null = null;
private _useHaDialog: boolean | null = null;
private _closing = false;
private _overlays: OverlayEntry[] = [];
private readonly _titleId = `hp-dialog-title-${++dialogSequence}`;
private _usesHaDialog(): boolean {
return this._useHaDialog === true && !this.alert;
}
connectedCallback(): void {
super.connectedCallback();
this._opener = this._deepActiveElement();
const root = this.getRootNode();
this._focusRoot = root;
const current = focusSessions.get(root);
const session = current || { dialogs: new Set<HpDialog>(), opener: this._opener };
session.dialogs.add(this);
focusSessions.set(root, session);
// A native fallback is a complete modal implementation, not a loading
// placeholder. Keep the branch stable for this instance so a lazily
// registered HA component cannot replace an open dialog mid-session.
if (this._useHaDialog === null) {
this._useHaDialog = !!customElements.get('ha-dialog');
}
this.addEventListener('keydown', this._onKeyDown, true);
queueMicrotask(() => {
// Re-entering the top layer lets the UA choose a default focus target.
// Restore our deterministic initial target only on a real reconnect
// recovery; ordinary updates must never steal focus from a live field.
if (this._ensureNativeModal()) this._focusInitial();
});
}
disconnectedCallback(): void {
this.removeEventListener('keydown', this._onKeyDown, true);
const overlays = [...this._overlays];
this._overlays = [];
for (const entry of overlays.reverse()) entry.close('disconnect');
const root = this._focusRoot;
const opener = this._opener;
this._opener = null;
this._focusRoot = null;
const session = root ? focusSessions.get(root) : undefined;
session?.dialogs.delete(this);
super.disconnectedCallback();
if (!root || !session) return;
requestAnimationFrame(() => {
const latest = focusSessions.get(root);
if (!latest) return;
if (!latest.dialogs.size) {
if (latest.opener?.isConnected) latest.opener.focus({ preventScroll: true });
focusSessions.delete(root);
return;
}
// Closing a child dialog returns to its trigger inside the parent. A
// dialog-to-dialog replacement intentionally waits for the last dialog,
// then restores the original trigger outside the whole modal session.
const owner = opener?.closest('hp-dialog') as HpDialog | null;
if (opener?.isConnected && owner && latest.dialogs.has(owner)) {
opener.focus({ preventScroll: true });
}
});
}
protected firstUpdated(changed: PropertyValues): void {
super.firstUpdated(changed);
this._ensureNativeModal();
queueMicrotask(() => this._focusInitial());
}
protected updated(changed: PropertyValues): void {
super.updated(changed);
this._ensureNativeModal();
}
/**
* A modal dialog leaves the browser top layer when its host is detached, but
* the native `open` flag can survive. Reconcile against `:modal`, not `open`,
* after every lifecycle boundary. Closing an open non-modal dialog before
* `showModal()` avoids InvalidStateError; a concurrent disconnect is benign
* and a later reconnect/update gets another bounded reconciliation attempt.
*/
private _ensureNativeModal(): boolean {
if (this._usesHaDialog() || !this.isConnected) return false;
const dialog = this.renderRoot.querySelector<HTMLDialogElement>('dialog');
if (!dialog?.isConnected) return false;
try {
if (dialog.matches(':modal')) return false;
if (dialog.open) dialog.close();
dialog.showModal();
return true;
} catch {
// No retry loop: a future update/reconnect is the next safe boundary.
return false;
}
}
private _deepActiveElement(): HTMLElement | null {
let active = document.activeElement as HTMLElement | null;
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement as HTMLElement;
return active && active !== document.body ? active : null;
}
private _focusableElements(): HTMLElement[] {
const selector = [
'[autofocus]',
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[contenteditable="true"]',
'[tabindex]:not([tabindex="-1"])',
].join(',');
const out: HTMLElement[] = [];
const seen = new Set<Node>();
const visit = (node: Node): void => {
if (seen.has(node)) return;
seen.add(node);
if (node instanceof HTMLElement) {
if (node.matches(selector)) out.push(node);
if (node instanceof HTMLSlotElement) {
for (const assigned of node.assignedNodes({ flatten: true })) visit(assigned);
return;
}
if (node.shadowRoot) {
for (const child of node.shadowRoot.childNodes) visit(child);
return;
}
}
for (const child of node.childNodes) visit(child);
};
for (const child of this.childNodes) visit(child);
const portal = this.overlayPortal();
if (portal) visit(portal);
return out.filter((el) => {
let current: HTMLElement | null = el;
while (current) {
const style = getComputedStyle(current);
if (current.hidden || current.inert || current.getAttribute('aria-hidden') === 'true'
|| style.display === 'none' || style.visibility === 'hidden') return false;
current = current.assignedSlot
|| current.parentElement
|| (current.getRootNode() instanceof ShadowRoot
? (current.getRootNode() as ShadowRoot).host as HTMLElement
: null);
if (current === this) break;
}
return true;
});
}
private _focusInitial = (): void => {
const focusable = this._focusableElements();
const autofocus = focusable.find((el) => el.hasAttribute('autofocus'));
const target = autofocus || focusable[0]
|| (!this._usesHaDialog() ? this.renderRoot.querySelector<HTMLElement>('.close') : null)
|| this.renderRoot.querySelector<HTMLElement>('.surface')
|| this.renderRoot.querySelector<HTMLElement>('ha-dialog');
target?.focus({ preventScroll: true });
};
private _requestClose = (): void => {
if (this._closing) return;
this._closing = true;
this.dispatchEvent(new CustomEvent('hp-close', { bubbles: true, composed: true }));
};
/**
* Let an owner reject an asynchronous close request (for example while a
* save/send is in flight) and keep the same modal usable afterwards.
*/
public rejectClose(): void {
this._closing = false;
this.requestUpdate();
}
private _pruneOverlays(): void {
this._overlays = this._overlays.filter((entry) => entry.owner.isConnected);
}
private _closeOverlay(entry: OverlayEntry, reason: HpOverlayCloseReason): void {
const index = this._overlays.findIndex((item) => item.token === entry.token);
if (index >= 0) this._overlays.splice(index, 1);
entry.close(reason);
}
public registerOverlay(registration: HpOverlayRegistration): () => void {
this._pruneOverlays();
const priorOwner = this._overlays.find((entry) => entry.owner === registration.owner);
if (priorOwner) this._overlays.splice(this._overlays.indexOf(priorOwner), 1);
const group = registration.group || 'transient';
for (const entry of [...this._overlays].reverse()) {
if (entry.group === group) this._closeOverlay(entry, 'exclusive');
}
const entry: OverlayEntry = { ...registration, group, token: Symbol('hp-overlay') };
this._overlays.push(entry);
let disposed = false;
return () => {
if (disposed) return;
disposed = true;
const index = this._overlays.findIndex((item) => item.token === entry.token);
if (index >= 0) this._overlays.splice(index, 1);
};
}
public closeTransientOverlays(reason: HpOverlayCloseReason = 'outside'): boolean {
this._pruneOverlays();
const entries = [...this._overlays].filter((entry) => (entry.group || 'transient') === 'transient');
for (const entry of entries.reverse()) this._closeOverlay(entry, reason);
return entries.length > 0;
}
public overlayPortal(): HTMLElement | null {
return this.renderRoot.querySelector<HTMLElement>('.overlay-portal');
}
private _onKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopImmediatePropagation();
this._pruneOverlays();
const overlay = this._overlays[this._overlays.length - 1];
if (overlay) {
this._closeOverlay(overlay, 'escape');
return;
}
this._requestClose();
return;
}
if (event.key !== 'Tab' || this._usesHaDialog()) return;
const close = this.renderRoot.querySelector<HTMLElement>('.close');
const focusable = close ? [close, ...this._focusableElements()] : this._focusableElements();
if (!focusable.length) {
event.preventDefault();
this.renderRoot.querySelector<HTMLElement>('.surface')?.focus({ preventScroll: true });
return;
}
const active = this._deepActiveElement();
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && (active === first || !focusable.includes(active!))) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
};
private _onFallbackCancel = (event: Event): void => {
event.preventDefault();
this._requestClose();
};
private _onFallbackClick = (event: MouseEvent): void => {
if (this.dismissOnScrim && event.target === event.currentTarget) this._requestClose();
};
protected render() {
const title = html`<span class="title" id=${this._titleId}>
${this.icon ? html`<ha-icon icon=${this.icon}></ha-icon>` : nothing}
<span class="title-text">${this.title}</span>
</span>`;
if (this._usesHaDialog()) {
if (this.describedBy) {
return html`<ha-dialog
.hass=${this.hass}
.open=${true}
width=${this.wide ? 'medium' : 'small'}
.preventScrimClose=${!this.dismissOnScrim}
.ariaLabelledBy=${this._titleId}
.ariaDescribedBy=${this.describedBy}
@opened=${this._focusInitial}
@closed=${this._requestClose}
>
<span class="header-title-slot" slot="headerTitle">${title}</span>
<slot></slot>
<span class="footer" slot="footer"><slot name="footer"></slot></span>
</ha-dialog><div class="overlay-portal"></div>`;
}
return html`<ha-dialog
.hass=${this.hass}
.open=${true}
width=${this.wide ? 'medium' : 'small'}
.preventScrimClose=${!this.dismissOnScrim}
.ariaLabelledBy=${this._titleId}
@opened=${this._focusInitial}
@closed=${this._requestClose}
>
<span class="header-title-slot" slot="headerTitle">${title}</span>
<slot></slot>
<span class="footer" slot="footer"><slot name="footer"></slot></span>
</ha-dialog><div class="overlay-portal"></div>`;
}
return html`<dialog
role=${this.alert ? 'alertdialog' : 'dialog'}
aria-modal="true"
aria-labelledby=${this._titleId}
aria-describedby=${this.describedBy || nothing}
@cancel=${this._onFallbackCancel}
@click=${this._onFallbackClick}
>
<section class="surface" tabindex="-1">
<header class="header">
${title}
<button class="close" type="button"
aria-label=${this.hass?.localize?.('ui.common.close') || 'Close'}
@click=${this._requestClose}>
<ha-icon icon="mdi:close"></ha-icon>
</button>
</header>
<div class="content"><slot></slot></div>
<div class="footer"><slot name="footer"></slot></div>
</section>
<div class="overlay-portal"></div>
</dialog>`;
}
}
if (!customElements.get('hp-dialog')) customElements.define('hp-dialog', HpDialog);
declare global {
interface HTMLElementTagNameMap {
'hp-dialog': HpDialog;
}
}