Files
houseplan-card/src/hp-dialog.ts
T

373 lines
11 KiB
TypeScript

import { LitElement, css, html, nothing, type PropertyValues } from 'lit';
type FocusSession = {
dialogs: Set<HpDialog>;
opener: HTMLElement | null;
};
// 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.
* The native dialog branch keeps the standalone demo usable without mocking HA
* frontend internals. 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 },
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);
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: auto;
max-width: none;
max-height: none;
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(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;
}
.close:hover,
.close:focus-visible {
background: rgb(127 127 127 / 0.16);
}
.content {
min-height: 0;
display: flex;
flex-direction: column;
}
`;
title = '';
icon = '';
wide = false;
dismissOnScrim = false;
hass: any = null;
private _opener: HTMLElement | null = null;
private _focusRoot: Node | null = null;
private _useHaDialog = false;
private _closing = false;
private readonly _titleId = `hp-dialog-title-${++dialogSequence}`;
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);
this._useHaDialog = !!customElements.get('ha-dialog');
this.addEventListener('keydown', this._onKeyDown, true);
}
disconnectedCallback(): void {
this.removeEventListener('keydown', this._onKeyDown, true);
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);
if (!this._useHaDialog) {
const dialog = this.renderRoot.querySelector('dialog');
if (dialog && !dialog.open) dialog.showModal();
}
queueMicrotask(() => this._focusInitial());
}
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(',');
return Array.from(this.querySelectorAll<HTMLElement>(selector)).filter((el) => {
const style = getComputedStyle(el);
return !el.hidden && style.display !== 'none' && style.visibility !== 'hidden';
});
}
private _focusInitial = (): void => {
const autofocus = this.querySelector<HTMLElement>('[autofocus]');
const target = autofocus || this._focusableElements()[0]
|| (!this._useHaDialog ? 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 }));
};
private _onKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopImmediatePropagation();
this._requestClose();
return;
}
if (event.key !== 'Tab' || this._useHaDialog) 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._useHaDialog) {
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>`;
}
return html`<dialog
role="dialog"
aria-modal="true"
aria-labelledby=${this._titleId}
@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>
</dialog>`;
}
}
if (!customElements.get('hp-dialog')) customElements.define('hp-dialog', HpDialog);
declare global {
interface HTMLElementTagNameMap {
'hp-dialog': HpDialog;
}
}