Add home assistant bottom sheet (#26948)

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
This commit is contained in:
Paul Bottein
2025-09-12 15:33:30 +02:00
committed by GitHub
parent 05775c411b
commit 046fc00f73
7 changed files with 385 additions and 300 deletions

View File

@@ -1,262 +1,62 @@
import { css, html, LitElement } from "lit"; import { css, html, LitElement, type PropertyValues } from "lit";
import { customElement, query, state } from "lit/decorators"; import "@home-assistant/webawesome/dist/components/drawer/drawer";
import { styleMap } from "lit/directives/style-map"; import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
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") @customElement("ha-bottom-sheet")
export class HaBottomSheet extends LitElement { 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; protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
@state() private _dialogMaxViewpointHeight = 70; if (changedProperties.has("open")) {
this._drawerOpen = this.open;
@state() private _dialogMinViewpointHeight = 55; }
}
@state() private _dialogViewportHeight?: number;
render() { render() {
return html`<dialog return html`
open <wa-drawer
@transitionend=${this._handleTransitionEnd} placement="bottom"
style=${styleMap({ .open=${this._drawerOpen}
height: this._dialogViewportHeight @wa-after-hide=${this._handleAfterHide}
? `${this._dialogViewportHeight}vh` without-header
: "auto", >
maxHeight: `${this._dialogMaxViewpointHeight}vh`, <slot></slot>
minHeight: `${this._dialogMinViewpointHeight}vh`, </wa-drawer>
})} `;
>
<div class="handle-wrapper">
<div
@mousedown=${this._handleMouseDown}
@touchstart=${this._handleTouchStart}
class="handle"
></div>
</div>
<slot></slot>
</dialog>`;
}
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` static styles = css`
.handle-wrapper { wa-drawer {
position: absolute; --wa-color-surface-raised: var(
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, --ha-dialog-surface-background,
var(--mdc-theme-surface, #fff) var(--mdc-theme-surface, #fff)
); );
display: flex; --spacing: 0;
flex-direction: column; --size: auto;
top: 0; --show-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
inset-inline-start: 0; --hide-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
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);
} }
wa-drawer::part(dialog) {
dialog.show { border-top-left-radius: var(--ha-border-radius-lg);
transform: translateY(0); 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 { interface HTMLElementTagNameMap {
"ha-bottom-sheet": HaBottomSheet; "ha-bottom-sheet": HaBottomSheet;
} }
interface HASSDomEvents {
"bottom-sheet-closed": undefined;
}
} }

View File

@@ -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`<dialog
open
@transitionend=${this._handleTransitionEnd}
style=${styleMap({
height: this._dialogViewportHeight
? `${this._dialogViewportHeight}vh`
: "auto",
maxHeight: `${this._dialogMaxViewpointHeight}vh`,
minHeight: `${this._dialogMinViewpointHeight}vh`,
})}
>
<div class="handle-wrapper">
<div
@mousedown=${this._handleMouseDown}
@touchstart=${this._handleTouchStart}
class="handle"
></div>
</div>
<slot></slot>
</dialog>`;
}
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;
}
}

View File

@@ -1,6 +1,7 @@
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-bottom-sheet";
import { createCloseHeading } from "../../components/ha-dialog"; import { createCloseHeading } from "../../components/ha-dialog";
import "../../components/ha-icon"; import "../../components/ha-icon";
import "../../components/ha-md-list"; import "../../components/ha-md-list";
@@ -40,6 +41,54 @@ export class ListItemsDialog
return nothing; return nothing;
} }
const content = html`
<div class="container">
<ha-md-list>
${this._params.items.map(
(item) => html`
<ha-md-list-item
type="button"
@click=${this._itemClicked}
.item=${item}
>
${item.iconPath
? html`
<ha-svg-icon
.path=${item.iconPath}
slot="start"
class="item-icon"
></ha-svg-icon>
`
: item.icon
? html`
<ha-icon
icon=${item.icon}
slot="start"
class="item-icon"
></ha-icon>
`
: nothing}
<span class="headline">${item.label}</span>
${item.description
? html`
<span class="supporting-text">${item.description}</span>
`
: nothing}
</ha-md-list-item>
`
)}
</ha-md-list>
</div>
`;
if (this._params.mode === "bottom-sheet") {
return html`
<ha-bottom-sheet placement="bottom" open @closed=${this._dialogClosed}>
${content}
</ha-bottom-sheet>
`;
}
return html` return html`
<ha-dialog <ha-dialog
open open
@@ -47,43 +96,7 @@ export class ListItemsDialog
@closed=${this._dialogClosed} @closed=${this._dialogClosed}
hideActions hideActions
> >
<div class="container"> ${content}
<ha-md-list>
${this._params.items.map(
(item) => html`
<ha-md-list-item
type="button"
@click=${this._itemClicked}
.item=${item}
>
${item.iconPath
? html`
<ha-svg-icon
.path=${item.iconPath}
slot="start"
class="item-icon"
></ha-svg-icon>
`
: item.icon
? html`
<ha-icon
icon=${item.icon}
slot="start"
class="item-icon"
></ha-icon>
`
: nothing}
<span class="headline">${item.label}</span>
${item.description
? html`
<span class="supporting-text">${item.description}</span>
`
: nothing}
</ha-md-list-item>
`
)}
</ha-md-list>
</div>
</ha-dialog> </ha-dialog>
`; `;
} }

View File

@@ -11,6 +11,7 @@ interface ListItem {
export interface ListItemsDialogParams { export interface ListItemsDialogParams {
title?: string; title?: string;
items: ListItem[]; items: ListItem[];
mode?: "dialog" | "bottom-sheet";
} }
export const showListItemsDialog = ( export const showListItemsDialog = (

View File

@@ -1,7 +1,7 @@
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import "../../../components/ha-bottom-sheet"; import "../../../components/ha-resizable-bottom-sheet";
import type { HaBottomSheet } from "../../../components/ha-bottom-sheet"; import type { HaResizableBottomSheet } from "../../../components/ha-resizable-bottom-sheet";
import { import {
isCondition, isCondition,
isScriptField, isScriptField,
@@ -37,7 +37,8 @@ export default class HaAutomationSidebar extends LitElement {
@state() private _yamlMode = false; @state() private _yamlMode = false;
@query("ha-bottom-sheet") private _bottomSheetElement?: HaBottomSheet; @query("ha-resizable-bottom-sheet")
private _bottomSheetElement?: HaResizableBottomSheet;
private _renderContent() { private _renderContent() {
// get config type // get config type
@@ -147,9 +148,9 @@ export default class HaAutomationSidebar extends LitElement {
if (this.narrow) { if (this.narrow) {
return html` return html`
<ha-bottom-sheet @bottom-sheet-closed=${this._closeSidebar}> <ha-resizable-bottom-sheet @bottom-sheet-closed=${this._closeSidebar}>
${this._renderContent()} ${this._renderContent()}
</ha-bottom-sheet> </ha-resizable-bottom-sheet>
`; `;
} }

View File

@@ -230,10 +230,10 @@ class HUIRoot extends LitElement {
}, },
{ {
icon: mdiSofa, icon: mdiSofa,
key: "ui.panel.lovelace.menu.add_area", key: "ui.panel.lovelace.menu.create_area",
visible: true, visible: true,
action: this._addArea, action: this._createArea,
overflowAction: this._handleAddArea, overflowAction: this._handleCreateArea,
}, },
{ {
icon: mdiAccount, icon: mdiAccount,
@@ -366,6 +366,7 @@ class HUIRoot extends LitElement {
} }
showListItemsDialog(this, { showListItemsDialog(this, {
title: title, title: title,
mode: this.narrow ? "bottom-sheet" : "dialog",
items: i.subItems!.map((si) => ({ items: i.subItems!.map((si) => ({
iconPath: si.icon, iconPath: si.icon,
label: this.hass!.localize(si.key), label: this.hass!.localize(si.key),
@@ -837,14 +838,14 @@ class HUIRoot extends LitElement {
showNewAutomationDialog(this, { mode: "automation" }); showNewAutomationDialog(this, { mode: "automation" });
}; };
private _handleAddArea(ev: CustomEvent<RequestSelectedDetail>): void { private _handleCreateArea(ev: CustomEvent<RequestSelectedDetail>): void {
if (!shouldHandleRequestSelectedEvent(ev)) { if (!shouldHandleRequestSelectedEvent(ev)) {
return; return;
} }
this._addArea(); this._createArea();
} }
private _addArea = async () => { private _createArea = async () => {
await this.hass.loadFragmentTranslation("config"); await this.hass.loadFragmentTranslation("config");
showAreaRegistryDetailDialog(this, { showAreaRegistryDetailDialog(this, {
createEntry: async (values) => { createEntry: async (values) => {
@@ -854,13 +855,15 @@ class HUIRoot extends LitElement {
} }
showToast(this, { showToast(this, {
message: this.hass.localize( message: this.hass.localize(
"ui.panel.lovelace.menu.add_area_success" "ui.panel.lovelace.menu.create_area_success"
), ),
action: { action: {
action: () => { action: () => {
navigate(`/config/areas/area/${area.area_id}`); 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"
),
}, },
}); });
}, },

View File

@@ -7059,9 +7059,9 @@
"add": "Add to Home Assistant", "add": "Add to Home Assistant",
"add_device": "Add device", "add_device": "Add device",
"create_automation": "Create automation", "create_automation": "Create automation",
"add_area": "Add area", "create_area": "Create area",
"add_area_success": "Area added", "create_area_success": "Area created",
"add_area_action": "View area", "create_area_action": "View area",
"add_person_success": "Person added", "add_person_success": "Person added",
"add_person_action": "View persons", "add_person_action": "View persons",
"add_person": "Add person" "add_person": "Add person"