Files
houseplan-card/src/hp-dialog.ts
T
Claude 4c9e013276 fix(dialog): align HA settings form shell (#609)
Use Home Assistant's public dialog tokens for the reviewed 560 px canvas, height cap, single scroller and mobile fullscreen layout. Keep generic dialogs and the native fallback unchanged, with authentic-HA evidence and mutation witnesses.

Issue: #609
User-Visible: yes
2026-09-23 03:18:37 +03:00

661 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { LitElement, css, html, nothing, type PropertyValues } from 'lit';
import { live } from 'lit/directives/live.js';
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' },
// #508: ha-dialog scrolls its own `.body`; a consumer whose content is
// itself a scroll container (overflow:auto + overscroll-behavior:contain)
// must be height-bound by a flex column, or Chromium stops scroll chaining
// at the never-scrolling child and neither wheel nor touch reaches HA's
// scroller. `flex-content` forwards ha-dialog's public `flexcontent`.
flexContent: { type: Boolean, reflect: true, attribute: 'flex-content' },
// #600 §3.1 «Шапка»: бейдж с именем пространства справа от заголовка и
// признак «диалог настроек» — оболочка референса (560, один скроллер).
badge: { type: String },
formShell: { type: Boolean, reflect: true, attribute: 'form-shell' },
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;
}
/* #600 §3.1: бейдж имени пространства — 14 px, радиус 7, обрезка 180 px. */
.badge {
flex: none;
max-width: 180px;
padding: 5px 8px;
border-radius: 7px;
background: rgb(127 127 127 / 0.16);
color: var(--secondary-text-color, inherit);
font-size: .875rem;
font-weight: 400;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.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);
}
/* #600: диалоги настроек (атрибут form-shell) — оболочка референса. Ширина 560
через переменную, которую уже читает :host([wide]); высота min(940,
окно − 48); единственный скроллер — .content, тело формы высотой не
владеет (К6). Generic wide и остальные диалоги не трогаются (Q7). */
:host([form-shell]) {
--hp-dialog-wide-width: 560px;
}
:host([form-shell]) ha-dialog {
/* Public HA dialog hooks. Keep the authentic branch on the same outer
geometry as the native form shell without reaching into HA's shadow
tree. The light-DOM form owns its 16 px canvas padding, so HA must not
add a second inset around it. */
--ha-dialog-width-md: 560px;
--ha-dialog-max-height: min(940px, calc(100dvh - 48px));
--dialog-content-padding: 0;
}
:host([form-shell]) .surface { max-height: min(940px, calc(100vh - 48px)); }
:host([form-shell]) .content {
flex: 1 1 auto;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
background: var(--secondary-background-color, color-mix(in srgb, var(--card-background-color, var(--hp-bg, #202126)) 90%, var(--primary-text-color, #000)));
}
:host([form-shell]) .close { width: 44px; height: 44px; }
:host([form-shell]) .header { min-height: 70px; }
@media (max-width: 480px) {
:host([form-shell]) ha-dialog {
--ha-dialog-width-md: var(--safe-width, 100vw);
--ha-dialog-width-full: var(--safe-width, 100vw);
--ha-dialog-min-height: var(--safe-height, 100dvh);
--ha-dialog-max-height: var(--safe-height, 100dvh);
--ha-dialog-border-radius: 0;
--dialog-surface-margin-top: 0;
}
:host([form-shell]) .surface { width: 100vw; max-height: 100vh; border: 0; border-radius: 0; }
:host([form-shell]) .badge { max-width: 105px; }
}
/* HA itself switches every dialog to fullscreen when viewport height is
500 px or less. Its radius token is inherited from hp-dialog, so mirror
the public responsive token here instead of leaving rounded corners on
an otherwise edge-to-edge authentic HA surface. */
@media (max-height: 500px) {
:host([form-shell]) ha-dialog {
--ha-dialog-border-radius: 0;
--dialog-surface-margin-top: 0;
}
}
.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 = '';
badge = '';
formShell = false;
wide = false;
alert = false;
describedBy = '';
dismissOnScrim = false;
flexContent = 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 {
// #489: the host itself is the stable cross-dialog selector. Keep this
// outside the rendered HA/native branch so consumers never have to pierce
// a second shadow root or care which dialog implementation is active.
this.setAttribute('data-hp', 'dialog');
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');
}
// The branch is immutable for this connected instance. Reflect it only so
// the card's light-DOM form canvas can fill HA's body without changing the
// already-reviewed native fallback geometry.
this.toggleAttribute('ha-dialog-shell', this._usesHaDialog());
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;
// HA's close control can dismiss the nested WebAwesome surface before the
// public ha-dialog.open property reflects that transition. Commit an
// explicit closed phase so a later rejectClose() has a real false -> true
// Lit transition and reopens the same physical modal deterministically.
if (this.isConnected) this.requestUpdate();
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. The
* close request above first commits an explicit false phase; `live()` also
* reconciles a public property changed externally by an HA implementation.
*/
public rejectClose(): void {
this._closing = false;
if (this.isConnected) 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>
${this.badge ? html`<span class="badge" title=${this.badge}>${this.badge}</span>` : nothing}
</span>`;
if (this._usesHaDialog()) {
if (this.describedBy) {
return html`<ha-dialog
.hass=${this.hass}
.open=${live(!this._closing)}
width=${this.wide ? 'medium' : 'small'}
?flexcontent=${this.flexContent}
.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=${live(!this._closing)}
width=${this.wide ? 'medium' : 'small'}
?flexcontent=${this.flexContent}
.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"
data-hp="dialog-cancel"
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;
}
}