From 10d92986620c037a330df3bfb424fafb843d0890 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Wed, 3 Jun 2026 22:56:21 +0200 Subject: [PATCH] fix: keep PWA refresh menu above workspace header --- .changeset/pwa-refresh-menu.md | 2 +- .../components/appShell/AppRefreshControl.ts | 108 +++++++++++++++--- 2 files changed, 93 insertions(+), 17 deletions(-) diff --git a/.changeset/pwa-refresh-menu.md b/.changeset/pwa-refresh-menu.md index 0b55583..7bb77b6 100644 --- a/.changeset/pwa-refresh-menu.md +++ b/.changeset/pwa-refresh-menu.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Keep the PWA refresh control menu visible above the mobile tab navigation. +Keep the PWA refresh control menu visible above mobile tab navigation and workspace tab content. diff --git a/src/client/src/components/appShell/AppRefreshControl.ts b/src/client/src/components/appShell/AppRefreshControl.ts index 624abbd..90a050d 100644 --- a/src/client/src/components/appShell/AppRefreshControl.ts +++ b/src/client/src/components/appShell/AppRefreshControl.ts @@ -3,6 +3,7 @@ import { customElement, property, state } from "lit/decorators.js"; import { actionMenuPanelStyle } from "../actionMenu"; const REFRESH_LONG_PRESS_MS = 550; +const REFRESH_MENU_PORTAL_STYLE_ID = "pi-web-app-refresh-menu-portal-style"; @customElement("app-refresh-control") export class AppRefreshControl extends LitElement { @@ -10,7 +11,8 @@ export class AppRefreshControl extends LitElement { @property({ attribute: false }) onRefresh?: () => void | Promise; @property({ attribute: false }) onReload?: () => void; @state() private menuOpen = false; - @state() private menuStyle = ""; + private menuStyle = ""; + private menuPortal: HTMLDivElement | undefined; private longPressTimer: number | undefined; private suppressNextClick = false; @@ -24,6 +26,7 @@ export class AppRefreshControl extends LitElement { document.removeEventListener("click", this.onDocumentClick); document.removeEventListener("keydown", this.onDocumentKeyDown); this.clearLongPressTimer(); + this.removePortalMenu(); super.disconnectedCallback(); } @@ -44,17 +47,6 @@ export class AppRefreshControl extends LitElement { @pointercancel=${() => { this.clearLongPressTimer(); }} @pointerleave=${() => { this.clearLongPressTimer(); }} >${this.renderRefreshIcon()} - ${this.renderMenu()} - `; - } - - private renderMenu() { - if (!this.menuOpen) return null; - return html` - `; } @@ -100,7 +92,8 @@ export class AppRefreshControl extends LitElement { }; private readonly onDocumentClick = (event: MouseEvent): void => { - if (event.composedPath().includes(this)) return; + const path = event.composedPath(); + if (path.includes(this) || (this.menuPortal !== undefined && path.includes(this.menuPortal))) return; this.closeMenu(); }; @@ -114,11 +107,13 @@ export class AppRefreshControl extends LitElement { private openMenu(target: EventTarget | null): void { this.menuStyle = actionMenuPanelStyle(target, { constrainTo: "viewport" }); this.menuOpen = true; + this.renderPortalMenu(); } private closeMenu(): void { this.menuOpen = false; this.suppressNextClick = false; + this.removePortalMenu(); } private refresh(): void { @@ -137,15 +132,96 @@ export class AppRefreshControl extends LitElement { this.longPressTimer = undefined; } + private renderPortalMenu(): void { + const ownerDocument = this.ownerDocument; + ensurePortalMenuStyles(ownerDocument); + + const menu = this.menuPortal ?? ownerDocument.createElement("div"); + this.menuPortal = menu; + menu.className = "pi-web-app-refresh-menu-portal"; + menu.setAttribute("role", "menu"); + menu.setAttribute("style", this.menuStyle); + menu.replaceChildren( + this.createPortalMenuButton("Refresh app data", () => { this.refresh(); }), + this.createPortalMenuButton("Full page reload", () => { this.reload(); }), + ); + menu.addEventListener("click", this.onPortalMenuClick); + if (!menu.isConnected) ownerDocument.body.append(menu); + } + + private createPortalMenuButton(label: string, onClick: () => void): HTMLButtonElement { + const button = this.ownerDocument.createElement("button"); + button.type = "button"; + button.setAttribute("role", "menuitem"); + button.textContent = label; + button.addEventListener("click", (event) => { + event.stopPropagation(); + onClick(); + }); + return button; + } + + private removePortalMenu(): void { + this.menuPortal?.removeEventListener("click", this.onPortalMenuClick); + this.menuPortal?.remove(); + this.menuPortal = undefined; + } + + private readonly onPortalMenuClick = (event: MouseEvent): void => { + event.stopPropagation(); + }; + static override styles = css` :host { position: relative; z-index: 1; display: flex; align-items: center; pointer-events: auto; -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; } :host, :host * { -webkit-user-select: none; user-select: none; } .app-refresh-button { box-sizing: border-box; width: 36px; height: 36px; display: grid; place-items: center; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 0; line-height: 1; cursor: pointer; touch-action: manipulation; -webkit-touch-callout: none; } .app-refresh-icon { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; } .app-refresh-button.refreshing .app-refresh-icon { animation: app-refresh-spin .8s linear infinite; } - .app-refresh-menu { position: fixed; z-index: 10000; box-sizing: border-box; min-width: min(170px, calc(100vw - 16px)); overflow: auto; padding: 4px; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); box-shadow: 0 8px 24px var(--pi-shadow); overflow-wrap: anywhere; } - .app-refresh-menu button { display: block; width: 100%; border: 0; border-radius: 8px; background: transparent; color: var(--pi-text); padding: 7px 9px; text-align: left; white-space: normal; overflow-wrap: anywhere; cursor: pointer; } - .app-refresh-menu button:hover, .app-refresh-menu button:focus { background: var(--pi-selection-bg); } @keyframes app-refresh-spin { to { transform: rotate(360deg); } } `; } + +function ensurePortalMenuStyles(ownerDocument: Document): void { + if (ownerDocument.getElementById(REFRESH_MENU_PORTAL_STYLE_ID) !== null) return; + const style = ownerDocument.createElement("style"); + style.id = REFRESH_MENU_PORTAL_STYLE_ID; + style.textContent = ` + .pi-web-app-refresh-menu-portal { + position: fixed; + z-index: 2147483647; + box-sizing: border-box; + min-width: min(170px, calc(100vw - 16px)); + overflow: auto; + padding: 4px; + border: 1px solid var(--pi-border); + border-radius: 8px; + background: var(--pi-surface); + color: var(--pi-text); + box-shadow: 0 8px 24px var(--pi-shadow); + overflow-wrap: anywhere; + font: 14px system-ui, sans-serif; + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; + } + .pi-web-app-refresh-menu-portal button { + display: block; + width: 100%; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--pi-text); + padding: 7px 9px; + text-align: left; + white-space: normal; + overflow-wrap: anywhere; + font: inherit; + cursor: pointer; + } + .pi-web-app-refresh-menu-portal button:hover, + .pi-web-app-refresh-menu-portal button:focus { + background: var(--pi-selection-bg); + } + `; + ownerDocument.head.append(style); +}