diff --git a/src/components/ha-bottom-sheet.ts b/src/components/ha-bottom-sheet.ts
index 6c67d5aea5..4fc83ba5c7 100644
--- a/src/components/ha-bottom-sheet.ts
+++ b/src/components/ha-bottom-sheet.ts
@@ -1,262 +1,62 @@
-import { css, html, LitElement } from "lit";
-import { customElement, query, state } from "lit/decorators";
-import { styleMap } from "lit/directives/style-map";
-import { fireEvent } from "../common/dom/fire_event";
+import { css, html, LitElement, type PropertyValues } from "lit";
+import "@home-assistant/webawesome/dist/components/drawer/drawer";
+import { customElement, property, state } from "lit/decorators";
-const ANIMATION_DURATION_MS = 300;
+export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300;
-/**
- * A bottom sheet component that slides up from the bottom of the screen.
- *
- * The bottom sheet provides a draggable interface that allows users to resize
- * the sheet by dragging the handle at the top. It supports both mouse and touch
- * interactions and automatically closes when dragged below a 20% of screen height.
- *
- * @fires bottom-sheet-closed - Fired when the bottom sheet is closed
- *
- * @cssprop --ha-bottom-sheet-border-width - Border width for the sheet
- * @cssprop --ha-bottom-sheet-border-style - Border style for the sheet
- * @cssprop --ha-bottom-sheet-border-color - Border color for the sheet
- */
@customElement("ha-bottom-sheet")
export class HaBottomSheet extends LitElement {
- @query("dialog") private _dialog!: HTMLDialogElement;
+ @property({ type: Boolean }) public open = false;
- private _dragging = false;
+ @state() private _drawerOpen = false;
- private _dragStartY = 0;
+ private _handleAfterHide() {
+ this.open = false;
+ const ev = new Event("closed", {
+ bubbles: true,
+ composed: true,
+ });
+ this.dispatchEvent(ev);
+ }
- private _initialSize = 0;
-
- @state() private _dialogMaxViewpointHeight = 70;
-
- @state() private _dialogMinViewpointHeight = 55;
-
- @state() private _dialogViewportHeight?: number;
+ protected updated(changedProperties: PropertyValues): void {
+ super.updated(changedProperties);
+ if (changedProperties.has("open")) {
+ this._drawerOpen = this.open;
+ }
+ }
render() {
- return html``;
- }
-
- protected firstUpdated(changedProperties) {
- super.firstUpdated(changedProperties);
- this._openSheet();
- }
-
- private _openSheet() {
- requestAnimationFrame(() => {
- // trigger opening animation
- this._dialog.classList.add("show");
- });
- }
-
- public closeSheet() {
- requestAnimationFrame(() => {
- this._dialog.classList.remove("show");
- });
- }
-
- private _handleTransitionEnd() {
- if (this._dialog.classList.contains("show")) {
- // after show animation is done
- // - set the height to the natural height, to prevent content shift when switch content
- // - set max height to 90vh, so it opens at max 70vh but can be resized to 90vh
- this._dialogViewportHeight =
- (this._dialog.offsetHeight / window.innerHeight) * 100;
- this._dialogMaxViewpointHeight = 90;
- this._dialogMinViewpointHeight = 20;
- } else {
- // after close animation is done close dialog element and fire closed event
- this._dialog.close();
- fireEvent(this, "bottom-sheet-closed");
- }
- }
-
- connectedCallback() {
- super.connectedCallback();
-
- // register event listeners for drag handling
- document.addEventListener("mousemove", this._handleMouseMove);
- document.addEventListener("mouseup", this._handleMouseUp);
- document.addEventListener("touchmove", this._handleTouchMove, {
- passive: false,
- });
- document.addEventListener("touchend", this._handleTouchEnd);
- document.addEventListener("touchcancel", this._handleTouchEnd);
- }
-
- disconnectedCallback() {
- super.disconnectedCallback();
-
- // unregister event listeners for drag handling
- document.removeEventListener("mousemove", this._handleMouseMove);
- document.removeEventListener("mouseup", this._handleMouseUp);
- document.removeEventListener("touchmove", this._handleTouchMove);
- document.removeEventListener("touchend", this._handleTouchEnd);
- document.removeEventListener("touchcancel", this._handleTouchEnd);
- }
-
- private _handleMouseDown = (ev: MouseEvent) => {
- this._startDrag(ev.clientY);
- };
-
- private _handleTouchStart = (ev: TouchEvent) => {
- // Prevent the browser from interpreting this as a scroll/PTR gesture.
- ev.preventDefault();
- this._startDrag(ev.touches[0].clientY);
- };
-
- private _startDrag(clientY: number) {
- this._dragging = true;
- this._dragStartY = clientY;
- this._initialSize = (this._dialog.offsetHeight / window.innerHeight) * 100;
- document.body.style.setProperty("cursor", "grabbing");
- }
-
- private _handleMouseMove = (ev: MouseEvent) => {
- if (!this._dragging) {
- return;
- }
- this._updateSize(ev.clientY);
- };
-
- private _handleTouchMove = (ev: TouchEvent) => {
- if (!this._dragging) {
- return;
- }
- ev.preventDefault(); // Prevent scrolling
- this._updateSize(ev.touches[0].clientY);
- };
-
- private _updateSize(clientY: number) {
- const deltaY = this._dragStartY - clientY;
- const viewportHeight = window.innerHeight;
- const deltaVh = (deltaY / viewportHeight) * 100;
-
- // Calculate new size and clamp between 10vh and 90vh
- let newSize = this._initialSize + deltaVh;
- newSize = Math.max(10, Math.min(90, newSize));
-
- // on drag down and below 20vh
- if (newSize < 20 && deltaY < 0) {
- this._endDrag();
- this.closeSheet();
- return;
- }
-
- this._dialogViewportHeight = newSize;
- }
-
- private _handleMouseUp = () => {
- this._endDrag();
- };
-
- private _handleTouchEnd = () => {
- this._endDrag();
- };
-
- private _endDrag() {
- if (!this._dragging) {
- return;
- }
- this._dragging = false;
- document.body.style.removeProperty("cursor");
+ return html`
+
+
+
+ `;
}
static styles = css`
- .handle-wrapper {
- position: absolute;
- top: 0;
- width: 100%;
- padding-bottom: 2px;
- display: flex;
- justify-content: center;
- align-items: center;
- cursor: grab;
- touch-action: none;
- }
- .handle-wrapper .handle {
- height: 20px;
- width: 200px;
- display: flex;
- justify-content: center;
- align-items: center;
- z-index: 7;
- padding-bottom: 76px;
- }
- .handle-wrapper .handle::after {
- content: "";
- border-radius: 8px;
- height: 4px;
- background: var(--divider-color, #e0e0e0);
- width: 80px;
- }
- .handle-wrapper .handle:active::after {
- cursor: grabbing;
- }
- dialog {
- height: auto;
- max-height: 70vh;
- min-height: 30vh;
- background-color: var(
+ wa-drawer {
+ --wa-color-surface-raised: var(
--ha-dialog-surface-background,
var(--mdc-theme-surface, #fff)
);
- display: flex;
- flex-direction: column;
- top: 0;
- inset-inline-start: 0;
- position: fixed;
- width: calc(100% - 4px);
- max-width: 100%;
- border: none;
- box-shadow: var(--wa-shadow-l);
- padding: 0;
- margin: 0;
- top: auto;
- inset-inline-end: auto;
- bottom: 0;
- inset-inline-start: 0;
- box-shadow: 0px -8px 16px rgba(0, 0, 0, 0.2);
- border-top-left-radius: var(
- --ha-dialog-border-radius,
- var(--ha-border-radius-2xl)
- );
- border-top-right-radius: var(
- --ha-dialog-border-radius,
- var(--ha-border-radius-2xl)
- );
- transform: translateY(100%);
- transition: transform ${ANIMATION_DURATION_MS}ms ease;
- border-top-width: var(--ha-bottom-sheet-border-width);
- border-right-width: var(--ha-bottom-sheet-border-width);
- border-left-width: var(--ha-bottom-sheet-border-width);
- border-bottom-width: 0;
- border-style: var(--ha-bottom-sheet-border-style);
- border-color: var(--ha-bottom-sheet-border-color);
+ --spacing: 0;
+ --size: auto;
+ --show-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
+ --hide-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
}
-
- dialog.show {
- transform: translateY(0);
+ wa-drawer::part(dialog) {
+ border-top-left-radius: var(--ha-border-radius-lg);
+ border-top-right-radius: var(--ha-border-radius-lg);
+ max-height: 90vh;
+ }
+ wa-drawer::part(body) {
+ padding-bottom: var(--safe-area-inset-bottom);
}
`;
}
@@ -265,8 +65,4 @@ declare global {
interface HTMLElementTagNameMap {
"ha-bottom-sheet": HaBottomSheet;
}
-
- interface HASSDomEvents {
- "bottom-sheet-closed": undefined;
- }
}
diff --git a/src/components/ha-resizable-bottom-sheet.ts b/src/components/ha-resizable-bottom-sheet.ts
new file mode 100644
index 0000000000..d08b788781
--- /dev/null
+++ b/src/components/ha-resizable-bottom-sheet.ts
@@ -0,0 +1,271 @@
+import { css, html, LitElement } from "lit";
+import { customElement, query, state } from "lit/decorators";
+import { styleMap } from "lit/directives/style-map";
+import { fireEvent } from "../common/dom/fire_event";
+import { BOTTOM_SHEET_ANIMATION_DURATION_MS } from "./ha-bottom-sheet";
+
+/**
+ * A bottom sheet component that slides up from the bottom of the screen.
+ *
+ * The bottom sheet provides a draggable interface that allows users to resize
+ * the sheet by dragging the handle at the top. It supports both mouse and touch
+ * interactions and automatically closes when dragged below a 20% of screen height.
+ *
+ * @fires bottom-sheet-closed - Fired when the bottom sheet is closed
+ *
+ * @cssprop --ha-bottom-sheet-border-width - Border width for the sheet
+ * @cssprop --ha-bottom-sheet-border-style - Border style for the sheet
+ * @cssprop --ha-bottom-sheet-border-color - Border color for the sheet
+ */
+@customElement("ha-resizable-bottom-sheet")
+export class HaResizableBottomSheet extends LitElement {
+ @query("dialog") private _dialog!: HTMLDialogElement;
+
+ private _dragging = false;
+
+ private _dragStartY = 0;
+
+ private _initialSize = 0;
+
+ @state() private _dialogMaxViewpointHeight = 70;
+
+ @state() private _dialogMinViewpointHeight = 55;
+
+ @state() private _dialogViewportHeight?: number;
+
+ render() {
+ return html``;
+ }
+
+ protected firstUpdated(changedProperties) {
+ super.firstUpdated(changedProperties);
+ this._openSheet();
+ }
+
+ private _openSheet() {
+ requestAnimationFrame(() => {
+ // trigger opening animation
+ this._dialog.classList.add("show");
+ });
+ }
+
+ public closeSheet() {
+ requestAnimationFrame(() => {
+ this._dialog.classList.remove("show");
+ });
+ }
+
+ private _handleTransitionEnd() {
+ if (this._dialog.classList.contains("show")) {
+ // after show animation is done
+ // - set the height to the natural height, to prevent content shift when switch content
+ // - set max height to 90vh, so it opens at max 70vh but can be resized to 90vh
+ this._dialogViewportHeight =
+ (this._dialog.offsetHeight / window.innerHeight) * 100;
+ this._dialogMaxViewpointHeight = 90;
+ this._dialogMinViewpointHeight = 20;
+ } else {
+ // after close animation is done close dialog element and fire closed event
+ this._dialog.close();
+ fireEvent(this, "bottom-sheet-closed");
+ }
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+
+ // register event listeners for drag handling
+ document.addEventListener("mousemove", this._handleMouseMove);
+ document.addEventListener("mouseup", this._handleMouseUp);
+ document.addEventListener("touchmove", this._handleTouchMove, {
+ passive: false,
+ });
+ document.addEventListener("touchend", this._handleTouchEnd);
+ document.addEventListener("touchcancel", this._handleTouchEnd);
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+
+ // unregister event listeners for drag handling
+ document.removeEventListener("mousemove", this._handleMouseMove);
+ document.removeEventListener("mouseup", this._handleMouseUp);
+ document.removeEventListener("touchmove", this._handleTouchMove);
+ document.removeEventListener("touchend", this._handleTouchEnd);
+ document.removeEventListener("touchcancel", this._handleTouchEnd);
+ }
+
+ private _handleMouseDown = (ev: MouseEvent) => {
+ this._startDrag(ev.clientY);
+ };
+
+ private _handleTouchStart = (ev: TouchEvent) => {
+ // Prevent the browser from interpreting this as a scroll/PTR gesture.
+ ev.preventDefault();
+ this._startDrag(ev.touches[0].clientY);
+ };
+
+ private _startDrag(clientY: number) {
+ this._dragging = true;
+ this._dragStartY = clientY;
+ this._initialSize = (this._dialog.offsetHeight / window.innerHeight) * 100;
+ document.body.style.setProperty("cursor", "grabbing");
+ }
+
+ private _handleMouseMove = (ev: MouseEvent) => {
+ if (!this._dragging) {
+ return;
+ }
+ this._updateSize(ev.clientY);
+ };
+
+ private _handleTouchMove = (ev: TouchEvent) => {
+ if (!this._dragging) {
+ return;
+ }
+ ev.preventDefault(); // Prevent scrolling
+ this._updateSize(ev.touches[0].clientY);
+ };
+
+ private _updateSize(clientY: number) {
+ const deltaY = this._dragStartY - clientY;
+ const viewportHeight = window.innerHeight;
+ const deltaVh = (deltaY / viewportHeight) * 100;
+
+ // Calculate new size and clamp between 10vh and 90vh
+ let newSize = this._initialSize + deltaVh;
+ newSize = Math.max(10, Math.min(90, newSize));
+
+ // on drag down and below 20vh
+ if (newSize < 20 && deltaY < 0) {
+ this._endDrag();
+ this.closeSheet();
+ return;
+ }
+
+ this._dialogViewportHeight = newSize;
+ }
+
+ private _handleMouseUp = () => {
+ this._endDrag();
+ };
+
+ private _handleTouchEnd = () => {
+ this._endDrag();
+ };
+
+ private _endDrag() {
+ if (!this._dragging) {
+ return;
+ }
+ this._dragging = false;
+ document.body.style.removeProperty("cursor");
+ }
+
+ static styles = css`
+ .handle-wrapper {
+ position: absolute;
+ top: 0;
+ width: 100%;
+ padding-bottom: 2px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ cursor: grab;
+ touch-action: none;
+ }
+ .handle-wrapper .handle {
+ height: 20px;
+ width: 200px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 7;
+ padding-bottom: 76px;
+ }
+ .handle-wrapper .handle::after {
+ content: "";
+ border-radius: 8px;
+ height: 4px;
+ background: var(--divider-color, #e0e0e0);
+ width: 80px;
+ }
+ .handle-wrapper .handle:active::after {
+ cursor: grabbing;
+ }
+ dialog {
+ height: auto;
+ max-height: 70vh;
+ min-height: 30vh;
+ background-color: var(
+ --ha-dialog-surface-background,
+ var(--mdc-theme-surface, #fff)
+ );
+ display: flex;
+ flex-direction: column;
+ top: 0;
+ inset-inline-start: 0;
+ position: fixed;
+ width: calc(100% - 4px);
+ max-width: 100%;
+ border: none;
+ box-shadow: var(--wa-shadow-l);
+ padding: 0;
+ margin: 0;
+ top: auto;
+ inset-inline-end: auto;
+ bottom: 0;
+ inset-inline-start: 0;
+ box-shadow: 0px -8px 16px rgba(0, 0, 0, 0.2);
+ border-top-left-radius: var(
+ --ha-dialog-border-radius,
+ var(--ha-border-radius-2xl)
+ );
+ border-top-right-radius: var(
+ --ha-dialog-border-radius,
+ var(--ha-border-radius-2xl)
+ );
+ transform: translateY(100%);
+ transition: transform ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms ease;
+ border-top-width: var(--ha-bottom-sheet-border-width);
+ border-right-width: var(--ha-bottom-sheet-border-width);
+ border-left-width: var(--ha-bottom-sheet-border-width);
+ border-bottom-width: 0;
+ border-style: var(--ha-bottom-sheet-border-style);
+ border-color: var(--ha-bottom-sheet-border-color);
+ }
+
+ dialog.show {
+ transform: translateY(0);
+ }
+ `;
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-resizable-bottom-sheet": HaResizableBottomSheet;
+ }
+
+ interface HASSDomEvents {
+ "bottom-sheet-closed": undefined;
+ }
+}
diff --git a/src/dialogs/dialog-list-items/dialog-list-items.ts b/src/dialogs/dialog-list-items/dialog-list-items.ts
index 9d09bb0785..f854211237 100644
--- a/src/dialogs/dialog-list-items/dialog-list-items.ts
+++ b/src/dialogs/dialog-list-items/dialog-list-items.ts
@@ -1,6 +1,7 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
+import "../../components/ha-bottom-sheet";
import { createCloseHeading } from "../../components/ha-dialog";
import "../../components/ha-icon";
import "../../components/ha-md-list";
@@ -40,6 +41,54 @@ export class ListItemsDialog
return nothing;
}
+ const content = html`
+
+
+ ${this._params.items.map(
+ (item) => html`
+
+ ${item.iconPath
+ ? html`
+
+ `
+ : item.icon
+ ? html`
+
+ `
+ : nothing}
+ ${item.label}
+ ${item.description
+ ? html`
+ ${item.description}
+ `
+ : nothing}
+
+ `
+ )}
+
+
+ `;
+
+ if (this._params.mode === "bottom-sheet") {
+ return html`
+
+ ${content}
+
+ `;
+ }
+
return html`
-
-
- ${this._params.items.map(
- (item) => html`
-
- ${item.iconPath
- ? html`
-
- `
- : item.icon
- ? html`
-
- `
- : nothing}
- ${item.label}
- ${item.description
- ? html`
- ${item.description}
- `
- : nothing}
-
- `
- )}
-
-
+ ${content}
`;
}
diff --git a/src/dialogs/dialog-list-items/show-list-items-dialog.ts b/src/dialogs/dialog-list-items/show-list-items-dialog.ts
index c04a9bdaa7..838db6171f 100644
--- a/src/dialogs/dialog-list-items/show-list-items-dialog.ts
+++ b/src/dialogs/dialog-list-items/show-list-items-dialog.ts
@@ -11,6 +11,7 @@ interface ListItem {
export interface ListItemsDialogParams {
title?: string;
items: ListItem[];
+ mode?: "dialog" | "bottom-sheet";
}
export const showListItemsDialog = (
diff --git a/src/panels/config/automation/ha-automation-sidebar.ts b/src/panels/config/automation/ha-automation-sidebar.ts
index c90ec11648..721d99dcc5 100644
--- a/src/panels/config/automation/ha-automation-sidebar.ts
+++ b/src/panels/config/automation/ha-automation-sidebar.ts
@@ -1,7 +1,7 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
-import "../../../components/ha-bottom-sheet";
-import type { HaBottomSheet } from "../../../components/ha-bottom-sheet";
+import "../../../components/ha-resizable-bottom-sheet";
+import type { HaResizableBottomSheet } from "../../../components/ha-resizable-bottom-sheet";
import {
isCondition,
isScriptField,
@@ -37,7 +37,8 @@ export default class HaAutomationSidebar extends LitElement {
@state() private _yamlMode = false;
- @query("ha-bottom-sheet") private _bottomSheetElement?: HaBottomSheet;
+ @query("ha-resizable-bottom-sheet")
+ private _bottomSheetElement?: HaResizableBottomSheet;
private _renderContent() {
// get config type
@@ -147,9 +148,9 @@ export default class HaAutomationSidebar extends LitElement {
if (this.narrow) {
return html`
-
+
${this._renderContent()}
-
+
`;
}
diff --git a/src/panels/lovelace/hui-root.ts b/src/panels/lovelace/hui-root.ts
index 54462cbd7a..9dc64a0b84 100644
--- a/src/panels/lovelace/hui-root.ts
+++ b/src/panels/lovelace/hui-root.ts
@@ -230,10 +230,10 @@ class HUIRoot extends LitElement {
},
{
icon: mdiSofa,
- key: "ui.panel.lovelace.menu.add_area",
+ key: "ui.panel.lovelace.menu.create_area",
visible: true,
- action: this._addArea,
- overflowAction: this._handleAddArea,
+ action: this._createArea,
+ overflowAction: this._handleCreateArea,
},
{
icon: mdiAccount,
@@ -366,6 +366,7 @@ class HUIRoot extends LitElement {
}
showListItemsDialog(this, {
title: title,
+ mode: this.narrow ? "bottom-sheet" : "dialog",
items: i.subItems!.map((si) => ({
iconPath: si.icon,
label: this.hass!.localize(si.key),
@@ -837,14 +838,14 @@ class HUIRoot extends LitElement {
showNewAutomationDialog(this, { mode: "automation" });
};
- private _handleAddArea(ev: CustomEvent): void {
+ private _handleCreateArea(ev: CustomEvent): void {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
}
- this._addArea();
+ this._createArea();
}
- private _addArea = async () => {
+ private _createArea = async () => {
await this.hass.loadFragmentTranslation("config");
showAreaRegistryDetailDialog(this, {
createEntry: async (values) => {
@@ -854,13 +855,15 @@ class HUIRoot extends LitElement {
}
showToast(this, {
message: this.hass.localize(
- "ui.panel.lovelace.menu.add_area_success"
+ "ui.panel.lovelace.menu.create_area_success"
),
action: {
action: () => {
navigate(`/config/areas/area/${area.area_id}`);
},
- text: this.hass.localize("ui.panel.lovelace.menu.add_area_action"),
+ text: this.hass.localize(
+ "ui.panel.lovelace.menu.create_area_action"
+ ),
},
});
},
diff --git a/src/translations/en.json b/src/translations/en.json
index 3a0af8c881..6e7c256c41 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -7059,9 +7059,9 @@
"add": "Add to Home Assistant",
"add_device": "Add device",
"create_automation": "Create automation",
- "add_area": "Add area",
- "add_area_success": "Area added",
- "add_area_action": "View area",
+ "create_area": "Create area",
+ "create_area_success": "Area created",
+ "create_area_action": "View area",
"add_person_success": "Person added",
"add_person_action": "View persons",
"add_person": "Add person"