Skip to content

Commit 4f4343d

Browse files
authored
Automation editor mobile bottom sheet (home-assistant#26680)
1 parent 7ab27d6 commit 4f4343d

22 files changed

+525
-123
lines changed

src/components/ha-bottom-sheet.ts

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
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";
5+
6+
const ANIMATION_DURATION_MS = 300;
7+
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+
*/
21+
@customElement("ha-bottom-sheet")
22+
export class HaBottomSheet extends LitElement {
23+
@query("dialog") private _dialog!: HTMLDialogElement;
24+
25+
private _dragging = false;
26+
27+
private _dragStartY = 0;
28+
29+
private _initialSize = 0;
30+
31+
@state() private _dialogMaxViewpointHeight = 70;
32+
33+
@state() private _dialogViewportHeight?: number;
34+
35+
render() {
36+
return html`<dialog
37+
open
38+
@transitionend=${this._handleTransitionEnd}
39+
style=${styleMap({
40+
height: this._dialogViewportHeight
41+
? `${this._dialogViewportHeight}vh`
42+
: "auto",
43+
maxHeight: `${this._dialogMaxViewpointHeight}vh`,
44+
})}
45+
>
46+
<div class="handle-wrapper">
47+
<div
48+
@mousedown=${this._handleMouseDown}
49+
@touchstart=${this._handleTouchStart}
50+
class="handle"
51+
></div>
52+
</div>
53+
<slot></slot>
54+
</dialog>`;
55+
}
56+
57+
protected firstUpdated(changedProperties) {
58+
super.firstUpdated(changedProperties);
59+
this._openSheet();
60+
}
61+
62+
private _openSheet() {
63+
requestAnimationFrame(() => {
64+
// trigger opening animation
65+
this._dialog.classList.add("show");
66+
});
67+
}
68+
69+
public closeSheet() {
70+
requestAnimationFrame(() => {
71+
this._dialog.classList.remove("show");
72+
});
73+
}
74+
75+
private _handleTransitionEnd() {
76+
if (this._dialog.classList.contains("show")) {
77+
// after show animation is done
78+
// - set the height to the natural height, to prevent content shift when switch content
79+
// - set max height to 90vh, so it opens at max 70vh but can be resized to 90vh
80+
this._dialogViewportHeight =
81+
(this._dialog.offsetHeight / window.innerHeight) * 100;
82+
this._dialogMaxViewpointHeight = 90;
83+
} else {
84+
// after close animation is done close dialog element and fire closed event
85+
this._dialog.close();
86+
fireEvent(this, "bottom-sheet-closed");
87+
}
88+
}
89+
90+
connectedCallback() {
91+
super.connectedCallback();
92+
93+
// register event listeners for drag handling
94+
document.addEventListener("mousemove", this._handleMouseMove);
95+
document.addEventListener("mouseup", this._handleMouseUp);
96+
document.addEventListener("touchmove", this._handleTouchMove, {
97+
passive: false,
98+
});
99+
document.addEventListener("touchend", this._handleTouchEnd);
100+
document.addEventListener("touchcancel", this._handleTouchEnd);
101+
}
102+
103+
disconnectedCallback() {
104+
super.disconnectedCallback();
105+
106+
// unregister event listeners for drag handling
107+
document.removeEventListener("mousemove", this._handleMouseMove);
108+
document.removeEventListener("mouseup", this._handleMouseUp);
109+
document.removeEventListener("touchmove", this._handleTouchMove);
110+
document.removeEventListener("touchend", this._handleTouchEnd);
111+
document.removeEventListener("touchcancel", this._handleTouchEnd);
112+
}
113+
114+
private _handleMouseDown = (ev: MouseEvent) => {
115+
this._startDrag(ev.clientY);
116+
};
117+
118+
private _handleTouchStart = (ev: TouchEvent) => {
119+
// Prevent the browser from interpreting this as a scroll/PTR gesture.
120+
ev.preventDefault();
121+
this._startDrag(ev.touches[0].clientY);
122+
};
123+
124+
private _startDrag(clientY: number) {
125+
this._dragging = true;
126+
this._dragStartY = clientY;
127+
this._initialSize = (this._dialog.offsetHeight / window.innerHeight) * 100;
128+
document.body.style.setProperty("cursor", "grabbing");
129+
}
130+
131+
private _handleMouseMove = (ev: MouseEvent) => {
132+
if (!this._dragging) {
133+
return;
134+
}
135+
this._updateSize(ev.clientY);
136+
};
137+
138+
private _handleTouchMove = (ev: TouchEvent) => {
139+
if (!this._dragging) {
140+
return;
141+
}
142+
ev.preventDefault(); // Prevent scrolling
143+
this._updateSize(ev.touches[0].clientY);
144+
};
145+
146+
private _updateSize(clientY: number) {
147+
const deltaY = this._dragStartY - clientY;
148+
const viewportHeight = window.innerHeight;
149+
const deltaVh = (deltaY / viewportHeight) * 100;
150+
151+
// Calculate new size and clamp between 10vh and 90vh
152+
let newSize = this._initialSize + deltaVh;
153+
newSize = Math.max(10, Math.min(90, newSize));
154+
155+
// on drag down and below 20vh
156+
if (newSize < 20 && deltaY < 0) {
157+
this._endDrag();
158+
this.closeSheet();
159+
return;
160+
}
161+
162+
this._dialogViewportHeight = newSize;
163+
}
164+
165+
private _handleMouseUp = () => {
166+
this._endDrag();
167+
};
168+
169+
private _handleTouchEnd = () => {
170+
this._endDrag();
171+
};
172+
173+
private _endDrag() {
174+
if (!this._dragging) {
175+
return;
176+
}
177+
this._dragging = false;
178+
document.body.style.removeProperty("cursor");
179+
}
180+
181+
static styles = css`
182+
.handle-wrapper {
183+
position: absolute;
184+
top: 0;
185+
width: 100%;
186+
padding-bottom: 2px;
187+
display: flex;
188+
justify-content: center;
189+
align-items: center;
190+
cursor: grab;
191+
touch-action: none;
192+
}
193+
.handle-wrapper .handle {
194+
height: 20px;
195+
width: 200px;
196+
display: flex;
197+
justify-content: center;
198+
align-items: center;
199+
z-index: 7;
200+
}
201+
.handle-wrapper .handle::after {
202+
content: "";
203+
border-radius: 8px;
204+
height: 4px;
205+
background: var(--divider-color, #e0e0e0);
206+
width: 80px;
207+
}
208+
.handle-wrapper .handle:active::after {
209+
cursor: grabbing;
210+
}
211+
dialog {
212+
height: auto;
213+
max-height: 70vh;
214+
min-height: 30vh;
215+
background-color: var(
216+
--ha-dialog-surface-background,
217+
var(--mdc-theme-surface, #fff)
218+
);
219+
display: flex;
220+
flex-direction: column;
221+
top: 0;
222+
inset-inline-start: 0;
223+
position: fixed;
224+
width: calc(100% - 4px);
225+
max-width: 100%;
226+
border: none;
227+
box-shadow: var(--wa-shadow-l);
228+
padding: 0;
229+
margin: 0;
230+
231+
top: auto;
232+
inset-inline-end: auto;
233+
bottom: 0;
234+
inset-inline-start: 0;
235+
box-shadow: 0px -8px 16px rgba(0, 0, 0, 0.2);
236+
border-top-left-radius: var(
237+
--ha-dialog-border-radius,
238+
var(--ha-border-radius-2xl)
239+
);
240+
border-top-right-radius: var(
241+
--ha-dialog-border-radius,
242+
var(--ha-border-radius-2xl)
243+
);
244+
transform: translateY(100%);
245+
transition: transform ${ANIMATION_DURATION_MS}ms ease;
246+
border-top-width: var(--ha-bottom-sheet-border-width);
247+
border-right-width: var(--ha-bottom-sheet-border-width);
248+
border-left-width: var(--ha-bottom-sheet-border-width);
249+
border-bottom-width: 0;
250+
border-style: var(--ha-bottom-sheet-border-style);
251+
border-color: var(--ha-bottom-sheet-border-color);
252+
}
253+
254+
dialog.show {
255+
transform: translateY(0);
256+
}
257+
`;
258+
}
259+
260+
declare global {
261+
interface HTMLElementTagNameMap {
262+
"ha-bottom-sheet": HaBottomSheet;
263+
}
264+
265+
interface HASSDomEvents {
266+
"bottom-sheet-closed": undefined;
267+
}
268+
}

src/panels/config/automation/action/ha-automation-action-row.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -642,9 +642,6 @@ export default class HaAutomationActionRow extends LitElement {
642642
}
643643

644644
public openSidebar(action?: Action): void {
645-
if (this.narrow) {
646-
this.scrollIntoView();
647-
}
648645
const sidebarAction = action ?? this.action;
649646
const actionType = getAutomationActionType(sidebarAction);
650647

@@ -670,6 +667,13 @@ export default class HaAutomationActionRow extends LitElement {
670667
yamlMode: this._yamlMode,
671668
} satisfies ActionSidebarConfig);
672669
this._selected = true;
670+
671+
if (this.narrow) {
672+
this.scrollIntoView({
673+
block: "start",
674+
behavior: "smooth",
675+
});
676+
}
673677
}
674678

675679
public expand() {

src/panels/config/automation/action/ha-automation-action.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,12 +164,15 @@ export default class HaAutomationAction extends LitElement {
164164
!ACTION_BUILDING_BLOCKS.includes(type)
165165
) {
166166
row.openSidebar();
167+
if (this.narrow) {
168+
row.scrollIntoView({
169+
block: "start",
170+
behavior: "smooth",
171+
});
172+
}
167173
} else if (!this.optionsInSidebar) {
168174
row.expand();
169175
}
170-
if (this.narrow) {
171-
row.scrollIntoView();
172-
}
173176
row.focus();
174177
});
175178
}
@@ -185,6 +188,10 @@ export default class HaAutomationAction extends LitElement {
185188
}
186189

187190
private _addActionDialog() {
191+
if (this.narrow) {
192+
fireEvent(this, "close-sidebar");
193+
}
194+
188195
showAddAutomationElementDialog(this, {
189196
type: "action",
190197
add: this._addAction,

src/panels/config/automation/condition/ha-automation-condition-row.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -599,10 +599,6 @@ export default class HaAutomationConditionRow extends LitElement {
599599
}
600600

601601
public openSidebar(condition?: Condition): void {
602-
if (this.narrow) {
603-
this.scrollIntoView();
604-
}
605-
606602
const sidebarCondition = condition || this.condition;
607603
fireEvent(this, "open-sidebar", {
608604
save: (value) => {
@@ -626,6 +622,13 @@ export default class HaAutomationConditionRow extends LitElement {
626622
yamlMode: this._yamlMode,
627623
} satisfies ConditionSidebarConfig);
628624
this._selected = true;
625+
626+
if (this.narrow) {
627+
this.scrollIntoView({
628+
block: "start",
629+
behavior: "smooth",
630+
});
631+
}
629632
}
630633

631634
private _uiSupported = memoizeOne(

src/panels/config/automation/condition/ha-automation-condition.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,15 @@ export default class HaAutomationCondition extends LitElement {
108108
!CONDITION_BUILDING_BLOCKS.includes(row.condition.condition)
109109
) {
110110
row.openSidebar();
111+
if (this.narrow) {
112+
row.scrollIntoView({
113+
block: "start",
114+
behavior: "smooth",
115+
});
116+
}
111117
} else if (!this.optionsInSidebar) {
112118
row.expand();
113119
}
114-
if (this.narrow) {
115-
row.scrollIntoView();
116-
}
117120
row.focus();
118121
});
119122
}
@@ -205,6 +208,9 @@ export default class HaAutomationCondition extends LitElement {
205208
}
206209

207210
private _addConditionDialog() {
211+
if (this.narrow) {
212+
fireEvent(this, "close-sidebar");
213+
}
208214
showAddAutomationElementDialog(this, {
209215
type: "condition",
210216
add: this._addCondition,

0 commit comments

Comments
 (0)