diff --git a/gallery/src/pages/components/ha-adaptive-dialog.markdown b/gallery/src/pages/components/ha-adaptive-dialog.markdown new file mode 100644 index 0000000000..28613c7007 --- /dev/null +++ b/gallery/src/pages/components/ha-adaptive-dialog.markdown @@ -0,0 +1,3 @@ +--- +title: Adaptive dialog (ha-adaptive-dialog) +--- diff --git a/gallery/src/pages/components/ha-adaptive-dialog.ts b/gallery/src/pages/components/ha-adaptive-dialog.ts new file mode 100644 index 0000000000..81044688e0 --- /dev/null +++ b/gallery/src/pages/components/ha-adaptive-dialog.ts @@ -0,0 +1,732 @@ +import { css, html, LitElement } from "lit"; +import { customElement, state } from "lit/decorators"; +import { mdiCog, mdiHelp } from "@mdi/js"; +import "../../../../src/components/ha-button"; +import "../../../../src/components/ha-card"; +import "../../../../src/components/ha-dialog-footer"; +import "../../../../src/components/ha-adaptive-dialog"; +import "../../../../src/components/ha-form/ha-form"; +import "../../../../src/components/ha-icon-button"; +import type { HaFormSchema } from "../../../../src/components/ha-form/types"; +import { provideHass } from "../../../../src/fake_data/provide_hass"; +import type { HomeAssistant } from "../../../../src/types"; + +const SCHEMA: HaFormSchema[] = [ + { type: "string", name: "Name", default: "", autofocus: true }, + { type: "string", name: "Email", default: "" }, +]; + +type DialogType = + | false + | "basic" + | "basic-subtitle-below" + | "basic-subtitle-above" + | "form" + | "form-block-mode" + | "actions" + | "large" + | "small"; + +@customElement("demo-components-ha-adaptive-dialog") +export class DemoHaAdaptiveDialog extends LitElement { + @state() private _openDialog: DialogType = false; + + @state() private _hass?: HomeAssistant; + + protected firstUpdated() { + const hass = provideHass(this); + this._hass = hass; + } + + protected render() { + return html` +
+

Adaptive dialog <ha-adaptive-dialog>

+ +

+ Responsive dialog component that automatically switches between a full + dialog and bottom sheet based on screen size. +

+ +

Demos

+ +
+ Basic adaptive dialog + Adaptive dialog with subtitle below + Adaptive dialog with subtitle above + Small width adaptive dialog + Large width adaptive dialog + Adaptive dialog with form + Adaptive dialog with form (block mode change) + Adaptive dialog with actions +
+ + +
+

+ Tip: Resize your browser window to see the + responsive behavior. The dialog automatically switches to a bottom + sheet on narrow screens (<870px width) or short screens + (<500px height). +

+
+
+ + +
Adaptive dialog content
+
+ + +
Adaptive dialog content
+
+ + +
Adaptive dialog content
+
+ + +
This dialog uses the small width preset (320px).
+
+ + +
This dialog uses the large width preset (1024px).
+
+ + + + + Cancel + Submit + + + + + + + Cancel + Submit + + + + +
+ + +
+ +
Adaptive dialog content
+
+ +

Design

+ +

Responsive behavior

+ +

+ The ha-adaptive-dialog component automatically switches + between two modes based on screen size: +

+ + + +

+ The mode is determined automatically and updates when the window is + resized. To prevent mode changes after the initial mount (useful for + preventing form resets), use the block-mode-change + attribute. +

+ +

Width

+ +

+ In dialog mode, there are multiple width presets available. These are + ignored in bottom sheet mode. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
NameValue
smallmin(320px, var(--full-width))
mediummin(580px, var(--full-width))
largemin(1024px, var(--full-width))
fullvar(--full-width)
+ +

Adaptive dialogs have a default width of medium.

+ +

Header

+ +

+ The header contains a navigation icon, title, subtitle, and action + items. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
SlotDescription
headerNavigationIcon + Leading header action (e.g., close/back button). In bottom sheet + mode, defaults to a close button if not provided. +
headerTitleThe header title content.
headerSubtitleThe header subtitle content.
headerActionItemsTrailing header actions (e.g., icon buttons, menus).
+ +

Header title

+ +

+ The header title can be set using the header-title + attribute or by providing custom content in the + headerTitle slot. +

+ +

Header subtitle

+ +

+ The header subtitle can be set using the + header-subtitle attribute or by providing custom content + in the headerSubtitle slot. The subtitle position + relative to the title can be controlled with the + header-subtitle-position attribute. +

+ +

Header navigation icon

+ +

+ In bottom sheet mode, a close button is automatically provided if no + custom navigation icon is specified. In dialog mode, the dialog can be + closed via the standard dialog close button. +

+ +

Header action items

+ +

+ The header action items usually contain icon buttons and/or menu + buttons. +

+ +

Body

+ +

The body is the content of the adaptive dialog.

+ +

Footer

+ +

The footer is the footer of the adaptive dialog.

+ +

+ It is recommended to use the ha-dialog-footer component + for the footer and to style the buttons inside the footer as follows: +

+ + + + + + + + + + + + + + + + + + + + + +
SlotDescriptionVariant to use
secondaryActionThe secondary action button(s).plain
primaryActionThe primary action button(s).accent
+ +

Implementation

+ +

When to use

+ +

+ Use ha-adaptive-dialog when you need a dialog that should + adapt to different screen sizes automatically. This is particularly + useful for: +

+ + + +

+ If you don't need responsive behavior, use + ha-wa-dialog directly for desktop-only dialogs or + ha-bottom-sheet for mobile-only sheets. +

+ +

+ Use the block-mode-change attribute when you want to + prevent the dialog from switching modes after it's opened. This is + especially useful for forms, as it prevents form data from being lost + when users resize their browser window. +

+ +

Example usage

+ +
<ha-adaptive-dialog
+  .hass=\${this.hass}
+  open
+  width="medium"
+  header-title="Dialog title"
+  header-subtitle="Dialog subtitle"
+>
+  <div slot="headerActionItems">
+    <ha-icon-button label="Settings" path="mdiCog"></ha-icon-button>
+    <ha-icon-button label="Help" path="mdiHelp"></ha-icon-button>
+  </div>
+  <div>Dialog content</div>
+  <ha-dialog-footer slot="footer">
+    <ha-button slot="secondaryAction" variant="plain"
+      >Cancel</ha-button
+    >
+    <ha-button slot="primaryAction" variant="accent">Submit</ha-button>
+  </ha-dialog-footer>
+</ha-adaptive-dialog>
+ +

Example with block-mode-change for forms:

+ +
<ha-adaptive-dialog
+  .hass=\${this.hass}
+  open
+  header-title="Edit configuration"
+  block-mode-change
+>
+  <ha-form .schema=\${schema} .data=\${data}></ha-form>
+  <ha-dialog-footer slot="footer">
+    <ha-button slot="secondaryAction" variant="plain"
+      >Cancel</ha-button
+    >
+    <ha-button slot="primaryAction" variant="accent">Save</ha-button>
+  </ha-dialog-footer>
+</ha-adaptive-dialog>
+ +

API

+ +

+ This component combines ha-wa-dialog and + ha-bottom-sheet with automatic mode switching based on + screen size. +

+ +

Attributes

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AttributeDescriptionDefaultOptions
openControls the adaptive dialog open state.falsefalse, true
width + Preferred dialog width preset (dialog mode only, ignored in + bottom sheet mode). + medium + small, medium, large, + full +
header-titleHeader title text when no custom title slot is provided.
header-subtitle + Header subtitle text when no custom subtitle slot is provided. +
header-subtitle-positionPosition of the subtitle relative to the title.belowabove, below
aria-labelledby + The ID of the element that labels the dialog (for + accessibility). +
aria-describedby + The ID of the element that describes the dialog (for + accessibility). +
block-mode-change + When set, the mode is determined at mount time based on the + current screen size, but subsequent mode changes are blocked. + Useful for preventing forms from resetting when the viewport + size changes. + falsefalse, true
+ +

CSS custom properties

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
CSS PropertyDescription
--ha-dialog-surface-backgroundDialog/sheet background color.
--ha-dialog-border-radiusBorder radius of the dialog surface (dialog mode only).
--ha-dialog-show-durationShow animation duration (dialog mode only).
--ha-dialog-hide-durationHide animation duration (dialog mode only).
+ +

Events

+ + + + + + + + + + + + + + + + + + + + + + +
EventDescription
opened + Fired when the adaptive dialog is shown (dialog mode only). +
closed + Fired after the adaptive dialog is hidden (dialog mode only). +
after-showFired after show animation completes (dialog mode only).
+ +

Focus management

+ +

+ To automatically focus an element when the adaptive dialog opens, add + the + autofocus attribute to it. Components with + delegatesFocus: true (like ha-form) will + forward focus to their first focusable child. +

+ +

Example:

+ +
<ha-adaptive-dialog .hass=\${this.hass} open>
+  <ha-form autofocus .schema=\${schema}></ha-form>
+</ha-adaptive-dialog>
+
+ `; + } + + private _handleOpenDialog = (dialog: DialogType) => () => { + this._openDialog = dialog; + }; + + private _handleClosed = () => { + this._openDialog = false; + }; + + static styles = [ + css` + :host { + display: block; + padding: var(--ha-space-4); + } + + .content { + max-width: 1000px; + margin: 0 auto; + } + + h1 { + margin-top: 0; + margin-bottom: var(--ha-space-2); + } + + h2 { + margin-top: var(--ha-space-6); + margin-bottom: var(--ha-space-3); + } + + h3, + h4 { + margin-top: var(--ha-space-4); + margin-bottom: var(--ha-space-2); + } + + p { + margin: var(--ha-space-2) 0; + line-height: 1.6; + } + + ul { + margin: var(--ha-space-2) 0; + padding-left: var(--ha-space-5); + } + + li { + margin: var(--ha-space-1) 0; + line-height: 1.6; + } + + .subtitle { + color: var(--secondary-text-color); + font-size: 1.1em; + margin-bottom: var(--ha-space-4); + } + + table { + width: 100%; + border-collapse: collapse; + margin: var(--ha-space-3) 0; + } + + th, + td { + text-align: left; + padding: var(--ha-space-2); + border-bottom: 1px solid var(--divider-color); + } + + th { + font-weight: 500; + } + + code { + background-color: var(--secondary-background-color); + padding: 2px 6px; + border-radius: 4px; + font-family: monospace; + font-size: 0.9em; + } + + pre { + background-color: var(--secondary-background-color); + padding: var(--ha-space-3); + border-radius: 8px; + overflow-x: auto; + margin: var(--ha-space-3) 0; + } + + pre code { + background-color: transparent; + padding: 0; + } + + .buttons { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: var(--ha-space-2); + margin: var(--ha-space-4) 0; + } + + .card-content { + padding: var(--ha-space-3); + } + + a { + color: var(--primary-color); + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "demo-components-ha-adaptive-dialog": DemoHaAdaptiveDialog; + } +} diff --git a/gallery/src/pages/components/ha-wa-dialog.ts b/gallery/src/pages/components/ha-wa-dialog.ts index 5a88561e66..a5e121bd3e 100644 --- a/gallery/src/pages/components/ha-wa-dialog.ts +++ b/gallery/src/pages/components/ha-wa-dialog.ts @@ -139,7 +139,7 @@ export class DemoHaWaDialog extends LitElement { large - min(720px, var(--full-width)) + min(1024px, var(--full-width)) full diff --git a/src/components/ha-adaptive-dialog.ts b/src/components/ha-adaptive-dialog.ts new file mode 100644 index 0000000000..7c5d258375 --- /dev/null +++ b/src/components/ha-adaptive-dialog.ts @@ -0,0 +1,188 @@ +import { mdiClose } from "@mdi/js"; +import { css, html, LitElement } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import type { HomeAssistant } from "../types"; +import { listenMediaQuery } from "../common/dom/media_query"; +import "./ha-bottom-sheet"; +import "./ha-dialog-header"; +import "./ha-icon-button"; +import "./ha-wa-dialog"; +import type { DialogWidth } from "./ha-wa-dialog"; + +type DialogSheetMode = "dialog" | "bottom-sheet"; + +/** + * Home Assistant adaptive dialog component + * + * @element ha-adaptive-dialog + * @extends {LitElement} + * + * @summary + * A responsive dialog component that automatically switches between a full dialog (ha-wa-dialog) + * and a bottom sheet (ha-bottom-sheet) based on screen size. Uses dialog mode on larger screens + * (>870px width and >500px height) and bottom sheet mode on smaller screens or mobile devices. + * + * @slot headerNavigationIcon - Leading header action (e.g. close/back button). + * @slot headerTitle - Custom title content (used when header-title is not set). + * @slot headerSubtitle - Custom subtitle content (used when header-subtitle is not set). + * @slot headerActionItems - Trailing header actions (e.g. buttons, menus). + * @slot - Dialog/sheet content body. + * @slot footer - Dialog/sheet footer content. + * + * @cssprop --ha-dialog-surface-background - Dialog/sheet background color. + * @cssprop --ha-dialog-border-radius - Border radius of the dialog surface (dialog mode only). + * @cssprop --ha-dialog-show-duration - Show animation duration (dialog mode only). + * @cssprop --ha-dialog-hide-duration - Hide animation duration (dialog mode only). + * + * @attr {boolean} open - Controls the dialog/sheet open state. + * @attr {("small"|"medium"|"large"|"full")} width - Preferred dialog width preset (dialog mode only). Defaults to "medium". + * @attr {string} header-title - Header title text. If not set, the headerTitle slot is used. + * @attr {string} header-subtitle - Header subtitle text. If not set, the headerSubtitle slot is used. + * @attr {("above"|"below")} header-subtitle-position - Position of the subtitle relative to the title. Defaults to "below". + * @attr {boolean} block-mode-change - When set, the mode is determined at mount time based on the current screen size, but subsequent mode changes are blocked. Useful for preventing forms from resetting when the viewport size changes. + * + * @event opened - Fired when the dialog/sheet is shown (dialog mode only). + * @event closed - Fired after the dialog/sheet is hidden. + * @event after-show - Fired after show animation completes (dialog mode only). + * + * @remarks + * **Responsive Behavior:** + * The component automatically switches between dialog and bottom sheet modes based on viewport size. + * Dialog mode is used for screens wider than 870px and taller than 500px. + * Bottom sheet mode is used for mobile devices and smaller screens. + * + * When `block-mode-change` is set, the mode is determined once at mount time based on the initial + * screen size. Subsequent viewport size changes will not trigger mode switches, which is useful + * for preventing form resets or other state loss when users resize their browser window. + * + * **Focus Management:** + * To automatically focus an element when opened, add the `autofocus` attribute to it. + * Components with `delegatesFocus: true` (like `ha-form`) will forward focus to their first focusable child. + * Example: `` + */ +@customElement("ha-adaptive-dialog") +export class HaAdaptiveDialog extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: "aria-labelledby" }) + public ariaLabelledBy?: string; + + @property({ attribute: "aria-describedby" }) + public ariaDescribedBy?: string; + + @property({ type: Boolean, reflect: true }) + public open = false; + + @property({ type: String, reflect: true, attribute: "width" }) + public width: DialogWidth = "medium"; + + @property({ attribute: "header-title" }) + public headerTitle?: string; + + @property({ attribute: "header-subtitle" }) + public headerSubtitle?: string; + + @property({ type: String, attribute: "header-subtitle-position" }) + public headerSubtitlePosition: "above" | "below" = "below"; + + @property({ type: Boolean, attribute: "block-mode-change" }) + public blockModeChange = false; + + @state() private _mode: DialogSheetMode = "dialog"; + + private _unsubMediaQuery?: () => void; + + private _modeSet = false; + + connectedCallback() { + super.connectedCallback(); + this._unsubMediaQuery = listenMediaQuery( + "(max-width: 870px), (max-height: 500px)", + (matches) => { + if (!this._modeSet || !this.blockModeChange) { + this._mode = matches ? "bottom-sheet" : "dialog"; + this._modeSet = true; + } + } + ); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this._unsubMediaQuery?.(); + this._unsubMediaQuery = undefined; + this._modeSet = false; + } + + render() { + if (this._mode === "bottom-sheet") { + return html` + + + + + + ${this.headerTitle !== undefined + ? html` + ${this.headerTitle} + ` + : html``} + ${this.headerSubtitle !== undefined + ? html`${this.headerSubtitle}` + : html``} + + + + + + `; + } + + return html` + + + + + + + + + `; + } + + static get styles() { + return [ + css` + ha-bottom-sheet { + --ha-bottom-sheet-surface-background: var( + --ha-dialog-surface-background, + var(--card-background-color, var(--ha-color-surface-default)) + ); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-adaptive-dialog": HaAdaptiveDialog; + } +} diff --git a/src/components/ha-bottom-sheet.ts b/src/components/ha-bottom-sheet.ts index 7ff07d3e40..847f6ca4ea 100644 --- a/src/components/ha-bottom-sheet.ts +++ b/src/components/ha-bottom-sheet.ts @@ -62,6 +62,7 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) { ${this.renderScrollableFades()} + `; } @@ -238,6 +239,23 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) { var(--safe-area-inset-left) ); } + slot[name="footer"] { + display: block; + padding: var(--ha-space-0); + } + ::slotted([slot="footer"]) { + display: flex; + padding: var(--ha-space-3) var(--ha-space-4) var(--ha-space-4) + var(--ha-space-4); + gap: var(--ha-space-3); + justify-content: flex-end; + align-items: center; + width: 100%; + box-sizing: border-box; + } + :host([flexcontent]) slot[name="footer"] { + flex-shrink: 0; + } `, ]; } diff --git a/src/dialogs/restart/dialog-restart.ts b/src/dialogs/restart/dialog-restart.ts index 26815442c4..736299cf7b 100644 --- a/src/dialogs/restart/dialog-restart.ts +++ b/src/dialogs/restart/dialog-restart.ts @@ -15,7 +15,7 @@ import "../../components/ha-alert"; import "../../components/ha-expansion-panel"; import "../../components/ha-fade-in"; import "../../components/ha-icon-next"; -import "../../components/ha-wa-dialog"; +import "../../components/ha-adaptive-dialog"; import "../../components/ha-md-list"; import "../../components/ha-md-list-item"; import "../../components/ha-spinner"; @@ -109,7 +109,7 @@ class DialogRestart extends LitElement { const dialogTitle = this.hass.localize("ui.dialogs.restart.heading"); return html` - `} - + `; } @@ -405,7 +405,7 @@ class DialogRestart extends LitElement { haStyle, haStyleDialog, css` - ha-wa-dialog { + ha-adaptive-dialog { --dialog-content-padding: 0; }