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"