From 2247e570e4193941130fcbf187b6ea619ec28100 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 2 Nov 2026 14:12:06 +0100 Subject: [PATCH] fix(app): keep chat status toasts visible below floating headers Compact native chats extend beneath the floating header. Panel toasts need measured header clearance and a layer above its fade; placing them below the fade leaves an excessive gap. Keep dock overlays outside keyboard translation so opening the keyboard cannot move them offscreen. --- .../floating-header/clearance.test.ts | 16 +++++++ .../components/floating-header/clearance.ts | 6 +++ .../src/components/floating-header/index.tsx | 45 +++++++++++++++++-- packages/app/src/components/toast-host.tsx | 10 ++++- .../app/src/composer/dock/index.native.tsx | 4 +- .../composer/dock/overlay-placement.test.ts | 31 +++++++++++++ 6 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 packages/app/src/components/floating-header/clearance.test.ts create mode 100644 packages/app/src/components/floating-header/clearance.ts create mode 100644 packages/app/src/composer/dock/overlay-placement.test.ts diff --git a/packages/app/src/components/floating-header/clearance.test.ts b/packages/app/src/components/floating-header/clearance.test.ts new file mode 100644 index 0000000000..f23cdcefa8 --- /dev/null +++ b/packages/app/src/components/floating-header/clearance.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { floatingHeaderOverlayClearance } from "./clearance"; + +describe("floating header overlay clearance", () => { + it("keeps stationary status overlays 12 points below the measured header", () => { + for (const headerHeight of [72, 108, 132]) { + const toastTop = floatingHeaderOverlayClearance(true, headerHeight) + 12; + expect(toastTop).toBe(headerHeight + 12); + } + }); + + it("adds no clearance when the header is in flow or hidden for fullscreen", () => { + expect(floatingHeaderOverlayClearance(false, 108)).toBe(0); + expect(floatingHeaderOverlayClearance(false, 0)).toBe(0); + }); +}); diff --git a/packages/app/src/components/floating-header/clearance.ts b/packages/app/src/components/floating-header/clearance.ts new file mode 100644 index 0000000000..59339c0fe9 --- /dev/null +++ b/packages/app/src/components/floating-header/clearance.ts @@ -0,0 +1,6 @@ +/** Content fades beneath the header; status overlays render above that fade. */ +export const FLOATING_HEADER_FADE_OVERHANG = 48; + +export function floatingHeaderOverlayClearance(floating: boolean, headerHeight: number): number { + return floating ? headerHeight : 0; +} diff --git a/packages/app/src/components/floating-header/index.tsx b/packages/app/src/components/floating-header/index.tsx index 6e4cd525f1..d0266975a7 100644 --- a/packages/app/src/components/floating-header/index.tsx +++ b/packages/app/src/components/floating-header/index.tsx @@ -1,10 +1,30 @@ -import { useCallback, useState, type ReactNode } from "react"; +import { createContext, useContext, useCallback, useState, useId, type ReactNode } from "react"; import { View, type LayoutChangeEvent } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +import { Portal, PortalHost } from "@gorhom/portal"; +import { useRetainedPanelActive } from "@/components/retained-panel"; import { EdgeFade } from "@/components/ui/edge-fade"; +import { FLOATING_HEADER_FADE_OVERHANG, floatingHeaderOverlayClearance } from "./clearance"; + +const OverlayClearanceContext = createContext(0); +const OverlayHostContext = createContext(null); + +/** Keep status overlays above the fade without lifting the scrolling content. */ +export function FloatingHeaderOverlay({ children }: { children: ReactNode }) { + const hostName = useContext(OverlayHostContext); + const active = useRetainedPanelActive(); + if (!hostName) return children; + return active ? {children} : null; +} + +/** Clearance for stationary overlays inside this layout, measured to the header bottom. */ +export function useFloatingHeaderOverlayClearance(): number { + return useContext(OverlayClearanceContext); +} + /** How far the fade runs past the header's bottom edge, so content dissolves instead of clipping. */ -const FADE_OVERHANG = 48; +const FADE_OVERHANG = FLOATING_HEADER_FADE_OVERHANG; /** Share of the fade that stays opaque, so the header's title and icons keep contrast. */ const FADE_SOLID_FRACTION = 0.57; @@ -20,15 +40,24 @@ interface FloatingHeaderLayoutProps { * same tree position in both modes, so toggling `floating` never remounts them. */ export function FloatingHeaderLayout({ header, floating, children }: FloatingHeaderLayoutProps) { + const overlayHostName = useId(); const [headerHeight, setHeaderHeight] = useState(0); const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => { setHeaderHeight(Math.round(event.nativeEvent.layout.height)); }, []); + const overlayClearance = floatingHeaderOverlayClearance(floating, headerHeight); + return ( {floating ? null : header} - {children(floating ? headerHeight : 0)} + + + + {children(floating ? headerHeight : 0)} + + + {floating ? ( ) : null} + + + ); } @@ -55,6 +87,13 @@ const styles = StyleSheet.create({ flex: 1, minHeight: 0, }, + statusOverlay: { + position: "absolute", + top: 0, + bottom: 0, + left: 0, + right: 0, + }, overlay: { position: "absolute", top: 0, diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx index d9c8001f50..62a15bad04 100644 --- a/packages/app/src/components/toast-host.tsx +++ b/packages/app/src/components/toast-host.tsx @@ -1,3 +1,7 @@ +import { + FloatingHeaderOverlay, + useFloatingHeaderOverlayClearance, +} from "@/components/floating-header"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { @@ -190,9 +194,11 @@ export function ToastViewport({ return clearTimer; }, [clearTimer, durationMs, scheduleDismiss, toastId]); + const panelClearance = useFloatingHeaderOverlayClearance(); const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT; const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; - const topOffset = placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : 0; + const topOffset = + placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : panelClearance; const content = ( {content} : content; } function ToastCard({ diff --git a/packages/app/src/composer/dock/index.native.tsx b/packages/app/src/composer/dock/index.native.tsx index 4b6407c872..e3720ccbee 100644 --- a/packages/app/src/composer/dock/index.native.tsx +++ b/packages/app/src/composer/dock/index.native.tsx @@ -179,8 +179,8 @@ export function ComposerDock({ {composer} - {overlay} + {overlay} ); } @@ -214,8 +214,8 @@ export function ComposerDock({ {composer} - {overlay} + {overlay} ); } diff --git a/packages/app/src/composer/dock/overlay-placement.test.ts b/packages/app/src/composer/dock/overlay-placement.test.ts new file mode 100644 index 0000000000..da4262af12 --- /dev/null +++ b/packages/app/src/composer/dock/overlay-placement.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; +import ts from "typescript"; +import { expect, it } from "vitest"; + +it("keeps dock overlays outside keyboard translation in every native layout", () => { + const source = ts.createSourceFile( + "dock.tsx", + readFileSync(new URL("./index.native.tsx", import.meta.url), "utf8"), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + let surfaces = 0; + function visit(node: ts.Node) { + if ( + ts.isJsxElement(node) && + node.openingElement.tagName.getText(source) === "KeyboardTranslateView" + ) { + surfaces++; + function assertStationary(child: ts.Node) { + if (ts.isJsxExpression(child)) + expect(child.expression?.getText(source)).not.toBe("overlay"); + ts.forEachChild(child, assertStationary); + } + ts.forEachChild(node, assertStationary); + } + ts.forEachChild(node, visit); + } + visit(source); + expect(surfaces).toBe(2); +});