mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
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
661 lines
23 KiB
TypeScript
661 lines
23 KiB
TypeScript
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;
|
||
}
|
||
}
|