Skip to content

Commit 046fc00

Browse files
piitayawendevlin
andauthored
Add home assistant bottom sheet (#26948)
Co-authored-by: Wendelin <[email protected]>
1 parent 05775c4 commit 046fc00

File tree

7 files changed

+382
-297
lines changed

7 files changed

+382
-297
lines changed

src/components/ha-bottom-sheet.ts

Lines changed: 40 additions & 244 deletions
Original file line numberDiff line numberDiff line change
@@ -1,262 +1,62 @@
1-
import { css, html, LitElement } from "lit";
2-
import { customElement, query, state } from "lit/decorators";
3-
import { styleMap } from "lit/directives/style-map";
4-
import { fireEvent } from "../common/dom/fire_event";
1+
import { css, html, LitElement, type PropertyValues } from "lit";
2+
import "@home-assistant/webawesome/dist/components/drawer/drawer";
3+
import { customElement, property, state } from "lit/decorators";
54

6-
const ANIMATION_DURATION_MS = 300;
5+
export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300;
76

8-
/**
9-
* A bottom sheet component that slides up from the bottom of the screen.
10-
*
11-
* The bottom sheet provides a draggable interface that allows users to resize
12-
* the sheet by dragging the handle at the top. It supports both mouse and touch
13-
* interactions and automatically closes when dragged below a 20% of screen height.
14-
*
15-
* @fires bottom-sheet-closed - Fired when the bottom sheet is closed
16-
*
17-
* @cssprop --ha-bottom-sheet-border-width - Border width for the sheet
18-
* @cssprop --ha-bottom-sheet-border-style - Border style for the sheet
19-
* @cssprop --ha-bottom-sheet-border-color - Border color for the sheet
20-
*/
217
@customElement("ha-bottom-sheet")
228
export class HaBottomSheet extends LitElement {
23-
@query("dialog") private _dialog!: HTMLDialogElement;
9+
@property({ type: Boolean }) public open = false;
2410

25-
private _dragging = false;
11+
@state() private _drawerOpen = false;
2612

27-
private _dragStartY = 0;
28-
29-
private _initialSize = 0;
30-
31-
@state() private _dialogMaxViewpointHeight = 70;
32-
33-
@state() private _dialogMinViewpointHeight = 55;
34-
35-
@state() private _dialogViewportHeight?: number;
36-
37-
render() {
38-
return html`<dialog
39-
open
40-
@transitionend=${this._handleTransitionEnd}
41-
style=${styleMap({
42-
height: this._dialogViewportHeight
43-
? `${this._dialogViewportHeight}vh`
44-
: "auto",
45-
maxHeight: `${this._dialogMaxViewpointHeight}vh`,
46-
minHeight: `${this._dialogMinViewpointHeight}vh`,
47-
})}
48-
>
49-
<div class="handle-wrapper">
50-
<div
51-
@mousedown=${this._handleMouseDown}
52-
@touchstart=${this._handleTouchStart}
53-
class="handle"
54-
></div>
55-
</div>
56-
<slot></slot>
57-
</dialog>`;
58-
}
59-
60-
protected firstUpdated(changedProperties) {
61-
super.firstUpdated(changedProperties);
62-
this._openSheet();
63-
}
64-
65-
private _openSheet() {
66-
requestAnimationFrame(() => {
67-
// trigger opening animation
68-
this._dialog.classList.add("show");
69-
});
70-
}
71-
72-
public closeSheet() {
73-
requestAnimationFrame(() => {
74-
this._dialog.classList.remove("show");
75-
});
76-
}
77-
78-
private _handleTransitionEnd() {
79-
if (this._dialog.classList.contains("show")) {
80-
// after show animation is done
81-
// - set the height to the natural height, to prevent content shift when switch content
82-
// - set max height to 90vh, so it opens at max 70vh but can be resized to 90vh
83-
this._dialogViewportHeight =
84-
(this._dialog.offsetHeight / window.innerHeight) * 100;
85-
this._dialogMaxViewpointHeight = 90;
86-
this._dialogMinViewpointHeight = 20;
87-
} else {
88-
// after close animation is done close dialog element and fire closed event
89-
this._dialog.close();
90-
fireEvent(this, "bottom-sheet-closed");
91-
}
92-
}
93-
94-
connectedCallback() {
95-
super.connectedCallback();
96-
97-
// register event listeners for drag handling
98-
document.addEventListener("mousemove", this._handleMouseMove);
99-
document.addEventListener("mouseup", this._handleMouseUp);
100-
document.addEventListener("touchmove", this._handleTouchMove, {
101-
passive: false,
13+
private _handleAfterHide() {
14+
this.open = false;
15+
const ev = new Event("closed", {
16+
bubbles: true,
17+
composed: true,
10218
});
103-
document.addEventListener("touchend", this._handleTouchEnd);
104-
document.addEventListener("touchcancel", this._handleTouchEnd);
105-
}
106-
107-
disconnectedCallback() {
108-
super.disconnectedCallback();
109-
110-
// unregister event listeners for drag handling
111-
document.removeEventListener("mousemove", this._handleMouseMove);
112-
document.removeEventListener("mouseup", this._handleMouseUp);
113-
document.removeEventListener("touchmove", this._handleTouchMove);
114-
document.removeEventListener("touchend", this._handleTouchEnd);
115-
document.removeEventListener("touchcancel", this._handleTouchEnd);
116-
}
117-
118-
private _handleMouseDown = (ev: MouseEvent) => {
119-
this._startDrag(ev.clientY);
120-
};
121-
122-
private _handleTouchStart = (ev: TouchEvent) => {
123-
// Prevent the browser from interpreting this as a scroll/PTR gesture.
124-
ev.preventDefault();
125-
this._startDrag(ev.touches[0].clientY);
126-
};
127-
128-
private _startDrag(clientY: number) {
129-
this._dragging = true;
130-
this._dragStartY = clientY;
131-
this._initialSize = (this._dialog.offsetHeight / window.innerHeight) * 100;
132-
document.body.style.setProperty("cursor", "grabbing");
19+
this.dispatchEvent(ev);
13320
}
13421

135-
private _handleMouseMove = (ev: MouseEvent) => {
136-
if (!this._dragging) {
137-
return;
138-
}
139-
this._updateSize(ev.clientY);
140-
};
141-
142-
private _handleTouchMove = (ev: TouchEvent) => {
143-
if (!this._dragging) {
144-
return;
145-
}
146-
ev.preventDefault(); // Prevent scrolling
147-
this._updateSize(ev.touches[0].clientY);
148-
};
149-
150-
private _updateSize(clientY: number) {
151-
const deltaY = this._dragStartY - clientY;
152-
const viewportHeight = window.innerHeight;
153-
const deltaVh = (deltaY / viewportHeight) * 100;
154-
155-
// Calculate new size and clamp between 10vh and 90vh
156-
let newSize = this._initialSize + deltaVh;
157-
newSize = Math.max(10, Math.min(90, newSize));
158-
159-
// on drag down and below 20vh
160-
if (newSize < 20 && deltaY < 0) {
161-
this._endDrag();
162-
this.closeSheet();
163-
return;
22+
protected updated(changedProperties: PropertyValues): void {
23+
super.updated(changedProperties);
24+
if (changedProperties.has("open")) {
25+
this._drawerOpen = this.open;
16426
}
165-
166-
this._dialogViewportHeight = newSize;
16727
}
16828

169-
private _handleMouseUp = () => {
170-
this._endDrag();
171-
};
172-
173-
private _handleTouchEnd = () => {
174-
this._endDrag();
175-
};
176-
177-
private _endDrag() {
178-
if (!this._dragging) {
179-
return;
180-
}
181-
this._dragging = false;
182-
document.body.style.removeProperty("cursor");
29+
render() {
30+
return html`
31+
<wa-drawer
32+
placement="bottom"
33+
.open=${this._drawerOpen}
34+
@wa-after-hide=${this._handleAfterHide}
35+
without-header
36+
>
37+
<slot></slot>
38+
</wa-drawer>
39+
`;
18340
}
18441

18542
static styles = css`
186-
.handle-wrapper {
187-
position: absolute;
188-
top: 0;
189-
width: 100%;
190-
padding-bottom: 2px;
191-
display: flex;
192-
justify-content: center;
193-
align-items: center;
194-
cursor: grab;
195-
touch-action: none;
196-
}
197-
.handle-wrapper .handle {
198-
height: 20px;
199-
width: 200px;
200-
display: flex;
201-
justify-content: center;
202-
align-items: center;
203-
z-index: 7;
204-
padding-bottom: 76px;
205-
}
206-
.handle-wrapper .handle::after {
207-
content: "";
208-
border-radius: 8px;
209-
height: 4px;
210-
background: var(--divider-color, #e0e0e0);
211-
width: 80px;
212-
}
213-
.handle-wrapper .handle:active::after {
214-
cursor: grabbing;
215-
}
216-
dialog {
217-
height: auto;
218-
max-height: 70vh;
219-
min-height: 30vh;
220-
background-color: var(
43+
wa-drawer {
44+
--wa-color-surface-raised: var(
22145
--ha-dialog-surface-background,
22246
var(--mdc-theme-surface, #fff)
22347
);
224-
display: flex;
225-
flex-direction: column;
226-
top: 0;
227-
inset-inline-start: 0;
228-
position: fixed;
229-
width: calc(100% - 4px);
230-
max-width: 100%;
231-
border: none;
232-
box-shadow: var(--wa-shadow-l);
233-
padding: 0;
234-
margin: 0;
235-
top: auto;
236-
inset-inline-end: auto;
237-
bottom: 0;
238-
inset-inline-start: 0;
239-
box-shadow: 0px -8px 16px rgba(0, 0, 0, 0.2);
240-
border-top-left-radius: var(
241-
--ha-dialog-border-radius,
242-
var(--ha-border-radius-2xl)
243-
);
244-
border-top-right-radius: var(
245-
--ha-dialog-border-radius,
246-
var(--ha-border-radius-2xl)
247-
);
248-
transform: translateY(100%);
249-
transition: transform ${ANIMATION_DURATION_MS}ms ease;
250-
border-top-width: var(--ha-bottom-sheet-border-width);
251-
border-right-width: var(--ha-bottom-sheet-border-width);
252-
border-left-width: var(--ha-bottom-sheet-border-width);
253-
border-bottom-width: 0;
254-
border-style: var(--ha-bottom-sheet-border-style);
255-
border-color: var(--ha-bottom-sheet-border-color);
48+
--spacing: 0;
49+
--size: auto;
50+
--show-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
51+
--hide-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
25652
}
257-
258-
dialog.show {
259-
transform: translateY(0);
53+
wa-drawer::part(dialog) {
54+
border-top-left-radius: var(--ha-border-radius-lg);
55+
border-top-right-radius: var(--ha-border-radius-lg);
56+
max-height: 90vh;
57+
}
58+
wa-drawer::part(body) {
59+
padding-bottom: var(--safe-area-inset-bottom);
26060
}
26161
`;
26262
}
@@ -265,8 +65,4 @@ declare global {
26565
interface HTMLElementTagNameMap {
26666
"ha-bottom-sheet": HaBottomSheet;
26767
}
268-
269-
interface HASSDomEvents {
270-
"bottom-sheet-closed": undefined;
271-
}
27268
}

0 commit comments

Comments
 (0)