Skip to content

Commit 156120d

Browse files
committed
fix: Fix web ui scrolling and swipe
1 parent 00bde92 commit 156120d

5 files changed

Lines changed: 143 additions & 40 deletions

File tree

firmware/web-server/captive_portal/src/stylesheet.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ svg { fill: #9269fe; color: #9269fe; }
285285
.description-row > :nth-child(1) {
286286
flex-shrink: 0;
287287
color: rgba(146, 105, 254, 0.7);
288-
margin-top: 1px;
288+
line-height: 0;
289289
}
290290
.description-row > :nth-child(2) { flex: 1; min-width: 0; }
291291
.description-row a { color: #9269fe; }

firmware/web-server/ui/src/css/app.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,19 @@ import { css } from "lit";
22

33
export default css`
44
:host {
5-
display: block;
5+
display: flex;
6+
flex-direction: column;
7+
min-height: 100dvh;
68
margin: 0;
79
padding: 0;
810
}
11+
header {
12+
flex-shrink: 0;
13+
}
14+
esp-entity-table {
15+
flex: 1;
16+
min-height: 0;
17+
}
918
1019
.bg-orbs {
1120
position: fixed;

firmware/web-server/ui/src/css/esp-entity-table.ts

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,31 @@ import { css } from "lit";
22

33
export default css`
44
:host {
5-
display: block;
5+
display: flex;
6+
flex-direction: column;
67
position: relative;
78
}
89
10+
/* ── Nav sentinel (IntersectionObserver target, first child of row-flex .layout) ── */
11+
.nav-sentinel {
12+
width: 0;
13+
height: 1px;
14+
flex-shrink: 0;
15+
align-self: flex-start;
16+
visibility: hidden;
17+
pointer-events: none;
18+
}
19+
920
/* ── Two-column layout ── */
1021
.layout {
1122
display: flex;
1223
flex-direction: row;
1324
box-sizing: border-box;
1425
max-width: 960px;
26+
width: 100%;
1527
margin: 0 auto;
16-
min-height: calc(100vh - var(--header-height, 64px));
28+
flex: 1;
29+
min-height: calc(100dvh - var(--page-offset, var(--header-height, 64px)));
1730
}
1831
1932
/* ── Sidebar (desktop) ── */
@@ -23,16 +36,22 @@ export default css`
2336
display: flex;
2437
flex-direction: column;
2538
gap: 2px;
26-
padding: 12px 12px 72px;
39+
padding: 12px 12px 0;
2740
position: sticky;
2841
top: var(--header-height, 64px);
2942
align-self: flex-start;
30-
max-height: calc(100vh - var(--header-height, 64px));
31-
overflow-y: auto;
32-
scrollbar-width: none;
43+
max-height: calc(100dvh - var(--header-height, 64px));
44+
overflow: hidden;
3345
border-right: 1px solid rgba(127, 127, 127, 0.1);
3446
}
35-
.nav-group::-webkit-scrollbar { display: none; }
47+
.nav-items-scroll {
48+
flex: 1;
49+
overflow-y: auto;
50+
overflow-x: hidden;
51+
padding-bottom: 72px;
52+
scrollbar-width: thin;
53+
scrollbar-color: rgba(127, 127, 127, 0.2) transparent;
54+
}
3655
3756
/* ── Sidebar search ── */
3857
.nav-search-wrap {
@@ -187,11 +206,25 @@ export default css`
187206
flex: 1;
188207
min-width: 0;
189208
padding: 12px 24px 72px;
209+
transform-origin: center top;
210+
touch-action: pan-y;
211+
}
212+
213+
@keyframes swipe-in-right {
214+
from { opacity: 0.3; transform: translateX(32px); }
215+
to { opacity: 1; transform: translateX(0); }
216+
}
217+
@keyframes swipe-in-left {
218+
from { opacity: 0.3; transform: translateX(-32px); }
219+
to { opacity: 1; transform: translateX(0); }
190220
}
221+
.content-area.swipe-in-right { animation: swipe-in-right 0.22s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; }
222+
.content-area.swipe-in-left { animation: swipe-in-left 0.22s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; }
191223
192224
/* ── Mobile: horizontal tab bar ── */
193225
@media (max-width: 640px) {
194226
.nav-search-wrap { display: none; }
227+
.nav-items-scroll { display: contents; }
195228
.layout {
196229
flex-direction: column;
197230
max-width: none;
@@ -205,6 +238,7 @@ export default css`
205238
flex-wrap: nowrap;
206239
overflow-x: auto;
207240
overflow-y: hidden;
241+
scrollbar-width: none;
208242
padding: 8px 8px 8px 0;
209243
gap: 4px;
210244
border-right: none;
@@ -443,7 +477,7 @@ export default css`
443477
.description-row > :nth-child(1) {
444478
flex-shrink: 0;
445479
color: rgba(146, 105, 254, 0.7);
446-
margin-top: 1px;
480+
line-height: 0;
447481
}
448482
.description-row > :nth-child(2) {
449483
flex: 1;

firmware/web-server/ui/src/esp-app.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -180,13 +180,18 @@ export default class EspApp extends LitElement {
180180
this.getHardwareVersion();
181181

182182
const header = this.shadowRoot?.querySelector('header');
183+
const updatePageOffset = () => {
184+
const headerH = header?.offsetHeight ?? 64;
185+
const infobox = this.shadowRoot?.querySelector('infobox') as HTMLElement | null;
186+
const infoboxH = infobox?.offsetHeight ?? 0;
187+
document.documentElement.style.setProperty('--header-height', `${headerH}px`);
188+
document.documentElement.style.setProperty('--page-offset', `${headerH + infoboxH}px`);
189+
};
183190
if (header) {
184-
const updateHeaderHeight = () => {
185-
document.documentElement.style.setProperty('--header-height', `${header.offsetHeight}px`);
186-
};
187-
new ResizeObserver(updateHeaderHeight).observe(header);
188-
updateHeaderHeight();
191+
new ResizeObserver(updatePageOffset).observe(header);
189192
}
193+
new ResizeObserver(updatePageOffset).observe(this);
194+
updatePageOffset();
190195

191196
}
192197

firmware/web-server/ui/src/esp-entity-table.ts

Lines changed: 80 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ export class EntityTable extends LitElement implements RestAction {
134134
private _userSelectedGroup: boolean = false;
135135
private _touchStartX = 0;
136136
private _touchStartY = 0;
137+
private _swipeLocked: boolean | null = null;
137138
private _actionRenderer = new ActionRenderer();
138139
private _basePath = getBasePath();
139140
private groups: groupConfig[] = []
@@ -150,17 +151,20 @@ export class EntityTable extends LitElement implements RestAction {
150151
protected updated() {
151152
if (this._navScrollInitialized) return;
152153
const nav = this.shadowRoot?.querySelector('.nav-group') as HTMLElement | null;
153-
if (!nav) return;
154+
const sentinel = this.shadowRoot?.querySelector('.nav-sentinel') as HTMLElement | null;
155+
const layout = this.shadowRoot?.querySelector('.layout') as HTMLElement | null;
156+
if (!nav || !sentinel || !layout) return;
154157
this._navScrollInitialized = true;
155-
requestAnimationFrame(() => {
156-
const headerHeight = parseFloat(
157-
document.documentElement.style.getPropertyValue('--header-height') || '64'
158-
);
159-
const threshold = Math.max(0, nav.getBoundingClientRect().top + window.scrollY - headerHeight);
160-
const check = () => nav.classList.toggle('is-stuck', window.scrollY > threshold);
161-
window.addEventListener('scroll', check, { passive: true });
162-
check();
163-
});
158+
const headerHeight = parseFloat(
159+
document.documentElement.style.getPropertyValue('--header-height') || '64'
160+
);
161+
new IntersectionObserver(([entry]) => {
162+
nav.classList.toggle('is-stuck', !entry.isIntersecting);
163+
}, {
164+
rootMargin: `-${headerHeight}px 0px 0px 0px`,
165+
threshold: 1.0,
166+
}).observe(sentinel);
167+
layout.addEventListener('touchmove', this._onTouchMove, { passive: false });
164168
}
165169

166170
connectedCallback() {
@@ -407,30 +411,78 @@ export class EntityTable extends LitElement implements RestAction {
407411
return sortedGroupedMap;
408412
}
409413

414+
private _selectGroup(name: string, animDirection: -1 | 0 | 1 = 0) {
415+
this.activeGroup = name;
416+
this._userSelectedGroup = true;
417+
this.searchQuery = '';
418+
if (animDirection === 0) return;
419+
requestAnimationFrame(() => {
420+
const ca = this.shadowRoot?.querySelector('.content-area') as HTMLElement | null;
421+
if (!ca) return;
422+
const cls = animDirection > 0 ? 'swipe-in-right' : 'swipe-in-left';
423+
ca.classList.remove('swipe-in-right', 'swipe-in-left');
424+
void ca.offsetWidth;
425+
ca.classList.add(cls);
426+
ca.addEventListener('animationend', () => ca.classList.remove(cls), { once: true });
427+
});
428+
}
429+
410430
private _swipeToGroup(direction: 1 | -1) {
411431
const names = Array.from(this._groupBy(this.entities, "sorting_group").keys());
412432
const idx = names.indexOf(this.activeGroup);
413433
const next = idx + direction;
414434
if (next < 0 || next >= names.length) return;
415-
this.activeGroup = names[next];
416-
this._userSelectedGroup = true;
417-
this.searchQuery = '';
435+
this._selectGroup(names[next], direction);
418436
requestAnimationFrame(() => {
419437
const navItems = this.shadowRoot?.querySelectorAll<HTMLElement>('.nav-item');
420438
navItems?.[next]?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
421439
});
422440
}
423441

442+
private _clickGroup(name: string) {
443+
const names = Array.from(this._groupBy(this.entities, "sorting_group").keys());
444+
const dir = Math.sign(names.indexOf(name) - names.indexOf(this.activeGroup)) as -1 | 0 | 1;
445+
this._selectGroup(name, dir);
446+
}
447+
424448
private _onTouchStart = (e: TouchEvent) => {
425449
this._touchStartX = e.touches[0].clientX;
426450
this._touchStartY = e.touches[0].clientY;
451+
this._swipeLocked = null;
452+
const ca = this.shadowRoot?.querySelector('.content-area') as HTMLElement | null;
453+
if (ca) ca.style.transition = 'none';
454+
};
455+
456+
private _onTouchMove = (e: TouchEvent) => {
457+
const dx = e.touches[0].clientX - this._touchStartX;
458+
const dy = e.touches[0].clientY - this._touchStartY;
459+
if (this._swipeLocked === null) {
460+
if (Math.abs(dx) > 8 || Math.abs(dy) > 8)
461+
this._swipeLocked = Math.abs(dx) > Math.abs(dy);
462+
return;
463+
}
464+
if (!this._swipeLocked) return;
465+
e.preventDefault();
466+
const names = Array.from(this._groupBy(this.entities, "sorting_group").keys());
467+
const idx = names.indexOf(this.activeGroup);
468+
const atBoundary = (dx < 0 && idx >= names.length - 1) || (dx > 0 && idx <= 0);
469+
const ca = this.shadowRoot?.querySelector('.content-area') as HTMLElement | null;
470+
if (ca) {
471+
ca.style.transition = 'none';
472+
ca.style.transform = `translateX(${dx * (atBoundary ? 0.12 : 0.35)}px)`;
473+
}
427474
};
428475

429476
private _onTouchEnd = (e: TouchEvent) => {
430477
const dx = e.changedTouches[0].clientX - this._touchStartX;
431478
const dy = e.changedTouches[0].clientY - this._touchStartY;
432-
if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 50) {
479+
const ca = this.shadowRoot?.querySelector('.content-area') as HTMLElement | null;
480+
if (this._swipeLocked && Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 50) {
481+
if (ca) { ca.style.transition = 'none'; ca.style.transform = ''; }
433482
this._swipeToGroup(dx < 0 ? 1 : -1);
483+
} else if (ca?.style.transform) {
484+
ca.style.transition = 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)';
485+
ca.style.transform = '';
434486
}
435487
};
436488

@@ -462,7 +514,8 @@ export class EntityTable extends LitElement implements RestAction {
462514

463515
return html`
464516
<div class="layout" @touchstart="${this._onTouchStart}" @touchend="${this._onTouchEnd}">
465-
<nav class="nav-group" @touchstart="${(e: Event) => e.stopPropagation()}" @touchend="${(e: Event) => e.stopPropagation()}">
517+
<div class="nav-sentinel" aria-hidden="true"></div>
518+
<nav class="nav-group" @touchstart="${(e: Event) => e.stopPropagation()}" @touchmove="${(e: Event) => e.stopPropagation()}" @touchend="${(e: Event) => e.stopPropagation()}">
466519
<div class="nav-search-wrap">
467520
<iconify-icon icon="mdi:magnify" height="14px" class="nav-search-icon"></iconify-icon>
468521
<input
@@ -479,16 +532,18 @@ export class EntityTable extends LitElement implements RestAction {
479532
` : nothing}
480533
</div>
481534
<div class="nav-search-divider"></div>
482-
${elems.map(
483-
(group) => html`
484-
<button
485-
class="nav-item ${!isSearching && this.activeGroup === group.name ? "active" : ""}"
486-
@click="${() => { this.activeGroup = group.name; this._userSelectedGroup = true; this.searchQuery = ''; }}"
487-
>
488-
${group.name || EntityTable.ENTITY_UNDEFINED}
489-
</button>
490-
`
491-
)}
535+
<div class="nav-items-scroll">
536+
${elems.map(
537+
(group) => html`
538+
<button
539+
class="nav-item ${!isSearching && this.activeGroup === group.name ? "active" : ""}"
540+
@click="${() => this._clickGroup(group.name)}"
541+
>
542+
${group.name || EntityTable.ENTITY_UNDEFINED}
543+
</button>
544+
`
545+
)}
546+
</div>
492547
</nav>
493548
<div class="content-area">
494549
${isSearching ? html`

0 commit comments

Comments
 (0)