diff --git a/docs/floating-panels.md b/docs/floating-panels.md index d646dc609..12c91a046 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -78,6 +78,29 @@ ordinary portals regardless of `z-index`, which would hide app toasts and tooltips behind the menu. The shared overlay scale keeps menus below toasts and lets tooltip portals paint above both. +The shared overlay scale is relative for interactive surfaces: a base floating +panel is below a base modal, while a floating panel rendered from inside a modal +inherits that modal's layer and paints above it. Wrap portal content in +`OverlayLayerProvider`; do not assign one global menu z-index. Desktop web +comboboxes must use `overlay-root` too. Rendering them through React Native +Web's `` puts them in the browser top layer, where no ordinary modal +portal can cover them. + +Painting and keyboard ownership use the same relative layer model. Register +desktop modal, combobox, and dropdown focus scopes with `useWebOverlayRegistration`; the +highest painted scope alone receives overlay keys, traps focus, and restores +focus when it closes. Do not add component-local global Escape listeners: two +stacked overlays would both close on one keypress. + +If an overlay is rendered by a global host rather than beneath its opener in +the React tree, carry the opener's current layer through the host store and +restore it with `OverlayLayerProvider`. Otherwise painting and keyboard +ownership silently reset at the app root. When the opener is a global keyboard +action and has no component context to carry, resolve the host layer with +`useGlobalWebOverlayLayer` on its closed-to-open transition. It captures the +current top registered layer before the new host joins the stack; do not give a +global dialog a fixed root-derived modal layer. + ## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance A Portal escapes Android's hit-test, but it also escapes two things you were diff --git a/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts b/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts index fa184ae0a..b106810b8 100644 --- a/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts +++ b/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts @@ -100,11 +100,7 @@ async function selectMode(page: Page, label: string): Promise { await expect(searchInput).toBeVisible({ timeout: 10_000 }); await searchInput.fill(label); - const option = page - .getByRole("dialog") - .last() - .getByText(new RegExp(`^${escapeRegex(label)}$`, "i")) - .first(); + const option = popup.getByText(new RegExp(`^${escapeRegex(label)}$`, "i")).first(); await expect(option).toBeVisible({ timeout: 10_000 }); await option.click({ force: true }); await expect(searchInput).not.toBeVisible({ timeout: 5_000 }); diff --git a/packages/app/e2e/provider-settings-refresh.spec.ts b/packages/app/e2e/provider-settings-refresh.spec.ts index 14234cf80..3ade95634 100644 --- a/packages/app/e2e/provider-settings-refresh.spec.ts +++ b/packages/app/e2e/provider-settings-refresh.spec.ts @@ -1,3 +1,4 @@ +import type { Locator } from "@playwright/test"; import { expect, test, type Page } from "./fixtures"; import { expectComposerVisible } from "./helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; @@ -63,6 +64,33 @@ async function closeSheetByHeaderButton(page: Page, testId: string) { await expect(sheet).not.toBeVisible({ timeout: 10_000 }); } +async function expectOverlayAbove(page: Page, frontTestId: string, backTestId: string) { + const frontCoversBack = await page.evaluate( + ({ frontTestId: frontId, backTestId: backId }) => { + const front = document.querySelector(`[data-testid="${frontId}"]`); + const back = document.querySelector(`[data-testid="${backId}"]`); + if (!(front instanceof HTMLElement) || !(back instanceof HTMLElement)) return false; + + const frontRect = front.getBoundingClientRect(); + const backRect = back.getBoundingClientRect(); + const left = Math.max(frontRect.left, backRect.left); + const right = Math.min(frontRect.right, backRect.right); + const top = Math.max(frontRect.top, backRect.top); + const bottom = Math.min(frontRect.bottom, backRect.bottom); + if (left >= right || top >= bottom) return false; + + const topElement = document.elementFromPoint((left + right) / 2, (top + bottom) / 2); + return topElement != null && front.contains(topElement); + }, + { frontTestId, backTestId }, + ); + expect(frontCoversBack).toBe(true); +} + +async function hasFocusWithin(locator: Locator): Promise { + return locator.evaluate((element) => element.contains(document.activeElement)); +} + async function expectProviderSettingsVisible(page: Page) { await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 }); await expect(page.getByRole("button", { name: "Add model" })).toBeVisible(); @@ -89,7 +117,69 @@ async function exerciseProviderSettingsStack(page: Page) { await expectProviderSettingsVisible(page); } -test.describe("provider settings bottom-sheet stack", () => { +test.describe("provider settings overlay stack", () => { + test("provider settings covers the desktop model selector without closing it", async ({ + page, + }) => { + const session = await seedMockAgentWorkspace({ + repoPrefix: "provider-modal-layer-", + title: "Provider modal layer e2e", + }); + + try { + await openAgentRoute(page, session); + await expectComposerVisible(page); + + await page.getByRole("button", { name: /Select model/ }).click(); + const selector = page.getByTestId("combobox-desktop-container"); + await expect(selector).toBeVisible({ timeout: 10_000 }); + const searchInput = page.getByRole("textbox", { name: /search models/i }); + await expect(searchInput).toBeFocused(); + await page.keyboard.press("Shift+Tab"); + await expect.poll(() => hasFocusWithin(selector)).toBe(true); + + await page.keyboard.press("Shift+?"); + const shortcuts = page.getByTestId("keyboard-shortcuts-dialog"); + await expect(shortcuts).toBeVisible({ timeout: 10_000 }); + await expect(page.getByPlaceholder("Search shortcuts")).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(shortcuts).not.toBeVisible({ timeout: 10_000 }); + await expect(selector).toBeVisible(); + + await page.keyboard.press("ControlOrMeta+K"); + const commandCenter = page.getByTestId("command-center-panel"); + await expect(commandCenter).toBeVisible({ timeout: 10_000 }); + await expect(commandCenter.getByTestId("command-center-input")).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(commandCenter).not.toBeVisible({ timeout: 10_000 }); + await expect(selector).toBeVisible(); + + await page.keyboard.press("ControlOrMeta+K"); + await expect(commandCenter).toBeVisible({ timeout: 10_000 }); + await commandCenter.getByText("Add project", { exact: true }).click(); + const addProject = page.getByTestId("add-project-flow"); + await expect(addProject).toBeVisible({ timeout: 10_000 }); + await expect(addProject.getByTestId("add-project-flow-input")).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(addProject).not.toBeVisible({ timeout: 10_000 }); + await expect(selector).toBeVisible(); + + const settingsButton = page.getByTestId("selector-header-settings-mock"); + await settingsButton.click(); + + const settings = page.getByTestId("provider-settings-sheet"); + await expect(settings).toBeVisible({ timeout: 10_000 }); + await expectOverlayAbove(page, "provider-settings-sheet", "combobox-desktop-container"); + + await page.keyboard.press("Escape"); + await expect(settings).not.toBeVisible({ timeout: 10_000 }); + await expect(selector).toBeVisible(); + await expect(settingsButton).toBeFocused(); + } finally { + await session.cleanup(); + } + }); + test("provider settings and children close back through the model selector stack", async ({ page, }) => { diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx index 11d59048c..f66cbf254 100644 --- a/packages/app/src/command-center/command-center.tsx +++ b/packages/app/src/command-center/command-center.tsx @@ -31,6 +31,11 @@ import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregate import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { useOpenAddProject } from "@/hooks/use-open-add-project"; import { useProjects } from "@/hooks/use-projects"; +import { + OverlayLayerProvider, + useGlobalWebOverlayLayer, + useWebOverlayRegistration, +} from "@/lib/overlay-root"; import { useHosts } from "@/runtime/host-runtime"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; @@ -372,15 +377,6 @@ function useCommandCenterState(): CommandCenterState { return cancel; }, [open]); - useEffect(() => { - if (!open || !isWeb) return; - const listener = (event: KeyboardEvent) => { - if (key(event.key)) event.preventDefault(); - }; - window.addEventListener("keydown", listener, true); - return () => window.removeEventListener("keydown", listener, true); - }, [key, open]); - return { open, query, @@ -553,6 +549,7 @@ export function CommandCenter() { const state = useCommandCenterState(); const isCompact = useIsCompactFormFactor(); const showBottomSheet = isCompact && isNative; + const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet); const listRef = useRef>(null); const bottomSheetListRef = useRef(null); const bottomSheetInputRef = useRef>(null); @@ -618,6 +615,19 @@ export function CommandCenter() { [state], ); const submit = useCallback(() => state.key("Enter"), [state]); + const handleWebOverlayKeyDown = useCallback( + (event: KeyboardEvent) => { + if (!state.key(event.key)) return false; + event.preventDefault(); + return true; + }, + [state], + ); + const setWebOverlayScope = useWebOverlayRegistration({ + active: isWeb && state.open && !showBottomSheet, + layer: modalLayer, + onKeyDown: handleWebOverlayKeyDown, + }); const backdrop = useCallback( (props: React.ComponentProps) => ( @@ -663,27 +673,29 @@ export function CommandCenter() { } if (!state.open) return null; return ( - - - - - - + + + + + + + + + - - - + + ); } diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 07c157fa8..8d06a2181 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -6,7 +6,12 @@ import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "r import type { StyleProp, TextInputProps, ViewStyle } from "react-native"; import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root"; +import { + getOverlayRoot, + OverlayLayerProvider, + useGlobalWebOverlayLayer, + useWebOverlayRegistration, +} from "../lib/overlay-root"; import { BottomSheetBackdrop, BottomSheetScrollView, @@ -54,36 +59,8 @@ export interface SheetHeader { search?: SheetHeaderSearch; } -type EscHandler = () => void; -const escStack: EscHandler[] = []; -let escListenerAttached = false; const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFillObject }; -function handleEscKeyDown(event: KeyboardEvent) { - if (event.key !== "Escape") return; - const top = escStack[escStack.length - 1]; - if (!top) return; - event.stopPropagation(); - event.preventDefault(); - top(); -} - -function pushEscHandler(handler: EscHandler): () => void { - escStack.push(handler); - if (!escListenerAttached && typeof window !== "undefined") { - window.addEventListener("keydown", handleEscKeyDown, true); - escListenerAttached = true; - } - return () => { - const index = escStack.lastIndexOf(handler); - if (index !== -1) escStack.splice(index, 1); - if (escStack.length === 0 && escListenerAttached && typeof window !== "undefined") { - window.removeEventListener("keydown", handleEscKeyDown, true); - escListenerAttached = false; - } - }; -} - const styles = StyleSheet.create((theme) => ({ desktopOverlay: { ...StyleSheet.absoluteFillObject, @@ -91,7 +68,6 @@ const styles = StyleSheet.create((theme) => ({ justifyContent: "center", alignItems: "center", padding: theme.spacing[6], - zIndex: OVERLAY_Z.modal, pointerEvents: "auto" as const, }, desktopCard: { @@ -582,6 +558,7 @@ export function AdaptiveModalSheet({ }); const [shouldRenderWeb, setShouldRenderWeb] = useState(visible); const [isWebClosing, setIsWebClosing] = useState(false); + const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && !isMobile && shouldRenderWeb); const nativeModalDismissNotifiedRef = useRef(!visible); const handleDismiss = useCallback(() => { handleSheetDismiss(); @@ -610,19 +587,31 @@ export function AdaptiveModalSheet({ () => [ styles.desktopOverlay, isWeb && { + zIndex: modalLayer, opacity: isWebClosing ? 0 : 1, transitionDuration: `${WEB_EXIT_DURATION_MS}ms`, transitionProperty: "opacity", transitionTimingFunction: "ease", }, ], - [isWebClosing], + [isWebClosing, modalLayer], ); - useEffect(() => { - if (!isWeb || isMobile || !visible) return; - return pushEscHandler(onClose); - }, [visible, isMobile, onClose]); + const handleWebOverlayKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key !== "Escape") return false; + event.preventDefault(); + event.stopPropagation(); + onClose(); + return true; + }, + [onClose], + ); + const setWebOverlayScope = useWebOverlayRegistration({ + active: isWeb && !isMobile && visible, + layer: modalLayer, + onKeyDown: handleWebOverlayKeyDown, + }); useEffect(() => { if (visible) { @@ -700,7 +689,7 @@ export function AdaptiveModalSheet({ } const cardInner = ( - <> + {scrollable ? ( @@ -717,7 +706,7 @@ export function AdaptiveModalSheet({ {children} )} {footer ? {footer} : null} - + ); const desktopContent = ( @@ -727,7 +716,15 @@ export function AdaptiveModalSheet({ style={ABSOLUTE_FILL_STYLE} onPress={onClose} /> - {cardInner} + + {cardInner} + ); diff --git a/packages/app/src/components/add-project-flow.tsx b/packages/app/src/components/add-project-flow.tsx index cfdda3d86..3078062a7 100644 --- a/packages/app/src/components/add-project-flow.tsx +++ b/packages/app/src/components/add-project-flow.tsx @@ -11,7 +11,15 @@ import { Search, Server, } from "lucide-react-native"; -import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react"; +import { + createElement, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ComponentType, +} from "react"; import { Modal, Pressable, @@ -66,6 +74,11 @@ import { useFetchQuery } from "@/data/query"; import { getOpenProjectFailureReason, registerProjectDescriptor } from "@/hooks/open-project"; import { useIsLocalDaemon, useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; import { useCloneGithubProject, useOpenProject } from "@/hooks/use-open-project"; +import { + OverlayLayerProvider, + useGlobalWebOverlayLayer, + useWebOverlayRegistration, +} from "@/lib/overlay-root"; import { useHosts, useHostRuntimeClient, @@ -769,14 +782,20 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { [activeIndex, handleBack, rows, submitActive], ); - useEffect(() => { - if (!isWeb || typeof window === "undefined") return; - const listener = (event: KeyboardEvent) => { - if (handleKey(event.key)) event.preventDefault(); - }; - window.addEventListener("keydown", listener, true); - return () => window.removeEventListener("keydown", listener, true); - }, [handleKey]); + const modalLayer = useGlobalWebOverlayLayer("modal", isWeb); + const handleWebOverlayKeyDown = useCallback( + (event: KeyboardEvent) => { + if (!handleKey(event.key)) return false; + event.preventDefault(); + return true; + }, + [handleKey], + ); + const setWebOverlayScope = useWebOverlayRegistration({ + active: isWeb, + layer: modalLayer, + onKeyDown: handleWebOverlayKeyDown, + }); const handleNativeKeyPress = useCallback( ({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => { @@ -816,11 +835,12 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { ? joinDirectoryPath(page.parentPath, page.name.trim()) : null; - return ( + const modal = ( ); + + return createElement(OverlayLayerProvider, { layer: isWeb ? modalLayer : 0 }, modal); } const styles = StyleSheet.create((theme) => ({ diff --git a/packages/app/src/components/model-browser.tsx b/packages/app/src/components/model-browser.tsx index 7f656c8c4..e9c34b0ec 100644 --- a/packages/app/src/components/model-browser.tsx +++ b/packages/app/src/components/model-browser.tsx @@ -34,6 +34,7 @@ import { type ProviderSelectorProvider, } from "@/provider-selection/provider-selection"; import { useProviderSettingsStore } from "@/stores/provider-settings-store"; +import { useCurrentOverlayLayer } from "@/lib/overlay-root"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import { resolveInitialModelBrowserView, @@ -53,6 +54,36 @@ const ThemedSearch = withUnistyles(Search); const ThemedSettings = withUnistyles(Settings); const ThemedStar = withUnistyles(Star); +function ProviderSettingsAction({ + accessibilityLabel, + provider, + serverId, +}: { + accessibilityLabel: string; + provider: string; + serverId: string | null; +}) { + const overlayParentLayer = useCurrentOverlayLayer(); + const handlePress = useCallback(() => { + if (!serverId) return; + useProviderSettingsStore.getState().open({ serverId, provider, overlayParentLayer }); + }, [overlayParentLayer, provider, serverId]); + + return ( + + + + ); +} + const IndependentScrollGestureContext = createContext | null>( null, ); @@ -237,11 +268,6 @@ export function useModelBrowser({ setSearchQuery(value); }, []); - const openProviderSettings = useCallback(() => { - if (!serverId || view.kind !== "provider") return; - useProviderSettingsStore.getState().open({ serverId, provider: view.providerId }); - }, [serverId, view]); - const singleProviderView = providers.length === 1; const header = useMemo(() => { if (view.kind === "all") { @@ -254,19 +280,13 @@ export function useModelBrowser({ ), back: singleProviderView ? undefined : { onPress: handleBackToAll }, actions: ( - - - + /> ), search: { onChange: handleSearchQueryChange, @@ -279,7 +299,6 @@ export function useModelBrowser({ }, [ handleBackToAll, handleSearchQueryChange, - openProviderSettings, searchResetKey, serverId, singleProviderView, diff --git a/packages/app/src/components/provider-settings-host.tsx b/packages/app/src/components/provider-settings-host.tsx index 7518fc28f..f6ff75c74 100644 --- a/packages/app/src/components/provider-settings-host.tsx +++ b/packages/app/src/components/provider-settings-host.tsx @@ -1,11 +1,13 @@ import { useCallback } from "react"; import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet"; +import { OverlayLayerProvider } from "@/lib/overlay-root"; import { useProviderSettingsStore } from "@/stores/provider-settings-store"; export function ProviderSettingsHost() { const serverId = useProviderSettingsStore((state) => state.serverId); const provider = useProviderSettingsStore((state) => state.provider); const visible = useProviderSettingsStore((state) => state.visible); + const overlayParentLayer = useProviderSettingsStore((state) => state.overlayParentLayer); const close = useProviderSettingsStore((state) => state.close); const handleClose = useCallback(() => { @@ -17,12 +19,14 @@ export function ProviderSettingsHost() { } return ( - + + + ); } diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 2d862a133..50f328787 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -23,6 +23,7 @@ import { type StyleProp, type ViewStyle, } from "react-native"; +import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -62,6 +63,12 @@ import { } from "@/components/adaptive-modal-sheet"; import { FloatingSurface } from "@/components/ui/floating"; import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss"; +import { + getOverlayRoot, + OverlayLayerProvider, + useOverlayLayer, + useWebOverlayRegistration, +} from "@/lib/overlay-root"; import { buildDesktopFrameStyle } from "./combobox-frame-style"; export { buildDesktopFrameStyle } from "./combobox-frame-style"; @@ -863,45 +870,31 @@ function isDesktopKey(key: string): key is DesktopKey { return key === "ArrowDown" || key === "ArrowUp" || key === "Enter" || key === "Escape"; } -function useWebKeyboardListener( - isOpen: boolean, - handleDesktopKey: (key: DesktopKey, event?: KeyboardEvent) => void, -) { - useEffect(() => { - if (!IS_WEB || !isOpen) return; - - const handler = (event: KeyboardEvent) => { - if (!isDesktopKey(event.key)) return; - handleDesktopKey(event.key, event); - }; - - // react-native-web's TextInput can stop propagation on key events, so listen in capture phase. - window.addEventListener("keydown", handler, true); - return () => { - window.removeEventListener("keydown", handler, true); - }; - }, [handleDesktopKey, isOpen]); -} - -function dispatchDesktopKey(input: DesktopKeyHandlerInput, key: DesktopKey, event?: KeyboardEvent) { - if (!input.isOpen) return; - if (!IS_WEB && input.isMobile) return; +function dispatchDesktopKey( + input: DesktopKeyHandlerInput, + key: DesktopKey, + event?: KeyboardEvent, +): boolean { + if (!input.isOpen) return false; + if (!IS_WEB && input.isMobile) return false; if (key === "ArrowDown" || key === "ArrowUp") { event?.preventDefault(); handleDesktopArrowKey(input, key); - return; + return true; } if (key === "Enter") { - if (input.orderedVisibleOptions.length === 0) return; + if (input.orderedVisibleOptions.length === 0) return false; event?.preventDefault(); handleDesktopEnterKey(input); - return; + return true; } if (key === "Escape") { event?.preventDefault(); input.handleClose(); + return true; } + return false; } function resolveInitialActiveIndex( @@ -1050,8 +1043,10 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement { } interface DesktopBodyProps { + overlayLayer: number; isOpen: boolean; handleClose: () => void; + handleDesktopKey: (key: DesktopKey, event?: KeyboardEvent) => boolean; refs: ReturnType["refs"]; shouldUseDesktopFade: boolean; desktopFrameStyle: StyleProp; @@ -1166,14 +1161,38 @@ function DesktopComboboxOptionsBody(props: { } function DesktopComboboxBody(props: DesktopBodyProps): ReactElement { - return ( - - + const handleDesktopKey = props.handleDesktopKey; + const handleWebOverlayKeyDown = useCallback( + (event: KeyboardEvent) => { + if (!isDesktopKey(event.key)) return false; + return handleDesktopKey(event.key, event); + }, + [handleDesktopKey], + ); + const setWebOverlayScope = useWebOverlayRegistration({ + active: isWeb && props.isOpen, + layer: props.overlayLayer, + onKeyDown: handleWebOverlayKeyDown, + }); + const setFloatingRef = useCallback( + (node: View | null) => { + props.refs.setFloating(node); + setWebOverlayScope(node); + }, + [props.refs, setWebOverlayScope], + ); + + const overlay = ( + + {props.hasChildren ? ( @@ -1215,6 +1237,21 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement { {props.footer ? {props.footer} : null} + + ); + + if (isWeb && typeof document !== "undefined") { + return createPortal(overlay, getOverlayRoot()); + } + + return ( + + {overlay} ); } @@ -1257,6 +1294,7 @@ export function Combobox({ const resolvedEmptyText = emptyText ?? t("common.empty.noOptionsMatchSearch"); const resolvedTitle = title ?? t("common.actions.select"); const isMobile = useIsCompactFormFactor(); + const floatingLayer = useOverlayLayer("floating"); const safeAreaInsets = useSafeAreaInsets(); const titleColor = theme.colors.foreground; const effectiveOptionsPosition = resolveEffectiveOptionsPosition(isMobile, optionsPosition); @@ -1462,7 +1500,7 @@ export function Combobox({ const handleDesktopKey = useCallback( (key: DesktopKey, event?: KeyboardEvent) => { - dispatchDesktopKey( + return dispatchDesktopKey( { isOpen, isMobile, @@ -1479,7 +1517,6 @@ export function Combobox({ [activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions], ); - useWebKeyboardListener(isOpen, handleDesktopKey); useDismissKeyboardOnOpen(isOpen, isMobile); const handleIndicatorStyle = useMemo( @@ -1556,8 +1593,10 @@ export function Combobox({ return ( ({ desktopOverlay: { flex: 1, }, + desktopOverlayWeb: { + ...StyleSheet.absoluteFillObject, + pointerEvents: "auto" as const, + }, desktopBackdrop: { position: "absolute", top: 0, diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx index 8c8badfa0..0f21bdb23 100644 --- a/packages/app/src/components/ui/dropdown-menu.tsx +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -34,7 +34,12 @@ import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { isWeb } from "@/constants/platform"; import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss"; -import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root"; +import { + getOverlayRoot, + OverlayLayerProvider, + useOverlayLayer, + useWebOverlayRegistration, +} from "@/lib/overlay-root"; // Action status for menu items with loading/success feedback export type ActionStatus = "idle" | "pending" | "success"; @@ -185,6 +190,7 @@ function renderDropdownSurface(input: { content: ReactElement; surfaceNativeID: string; onExited: () => void; + scopeRef: (node: View | null) => void; }): ReactElement { const { frameStyle, @@ -195,6 +201,7 @@ function renderDropdownSurface(input: { content, surfaceNativeID, onExited, + scopeRef, } = input; const body = scrollable ? ( @@ -212,7 +219,9 @@ function renderDropdownSurface(input: { return ( ): ReactElement | null { const { t } = useTranslation(); + const floatingLayer = useOverlayLayer("floating"); const { open, setOpen, triggerRef, flushPendingSelect } = useDropdownMenuContext("DropdownMenuContent"); const [modalVisible, setModalVisible] = useState(false); @@ -502,16 +512,21 @@ export function DropdownMenuContent({ setOpen(false); }, [setOpen]); - useEffect(() => { - if (!isWeb || !modalVisible || typeof window === "undefined") return undefined; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape") return; + const handleWebOverlayKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key !== "Escape") return false; + event.preventDefault(); event.stopPropagation(); handleClose(); - }; - window.addEventListener("keydown", handleKeyDown, true); - return () => window.removeEventListener("keydown", handleKeyDown, true); - }, [handleClose, modalVisible]); + return true; + }, + [handleClose], + ); + const setWebOverlayScope = useWebOverlayRegistration({ + active: isWeb && modalVisible, + layer: floatingLayer, + onKeyDown: handleWebOverlayKeyDown, + }); // Measure trigger when opening useEffect(() => { @@ -631,27 +646,36 @@ export function DropdownMenuContent({ ); const overlay = ( - - - {!closing - ? renderDropdownSurface({ - frameStyle, - testID, - surfaceStyle, - scrollable, - scrollViewportStyle, - content, - surfaceNativeID, - onExited: () => setModalVisible(false), - }) - : null} - + + + + {!closing + ? renderDropdownSurface({ + frameStyle, + testID, + surfaceStyle, + scrollable, + scrollViewportStyle, + content, + surfaceNativeID, + scopeRef: setWebOverlayScope, + onExited: () => setModalVisible(false), + }) + : null} + + ); if (isWeb && typeof document !== "undefined") { @@ -905,7 +929,6 @@ const styles = StyleSheet.create((theme) => ({ }, overlayWeb: { ...StyleSheet.absoluteFillObject, - zIndex: OVERLAY_Z.modal, pointerEvents: "auto" as const, }, backdrop: { diff --git a/packages/app/src/hosts/host-chooser.tsx b/packages/app/src/hosts/host-chooser.tsx index 4f8debd14..d0724e9cf 100644 --- a/packages/app/src/hosts/host-chooser.tsx +++ b/packages/app/src/hosts/host-chooser.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Modal, Pressable, @@ -13,8 +13,13 @@ import { Server } from "lucide-react-native"; import { create } from "zustand"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { HostStatusDotSlot } from "@/components/hosts/host-picker"; -import { isNative } from "@/constants/platform"; +import { isWeb } from "@/constants/platform"; import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; +import { + OverlayLayerProvider, + useGlobalWebOverlayLayer, + useWebOverlayRegistration, +} from "@/lib/overlay-root"; import { useHosts } from "@/runtime/host-runtime"; import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection"; import { buildSettingsAddHostRoute } from "@/utils/host-routes"; @@ -143,6 +148,7 @@ export function HostChooserModal() { const inputRef = useRef(null); const [query, setQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(0); + const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && request != null); const requestHosts = useMemo(() => { if (!request) return []; @@ -182,58 +188,60 @@ export function HostChooserModal() { [close, request], ); - useEffect(() => { - if (!request || isNative || typeof window === "undefined") return; - - const handleKeyDown = (event: KeyboardEvent) => { + const handleWebOverlayKeyDown = useCallback( + (event: KeyboardEvent) => { if ( event.key !== "ArrowDown" && event.key !== "ArrowUp" && event.key !== "Enter" && event.key !== "Escape" ) { - return; + return false; } if (event.key === "Escape") { event.preventDefault(); close(); - return; + return true; } if (event.key === "Enter") { const host = options[activeOptionIndex]; - if (!host) return; + if (!host) return false; event.preventDefault(); chooseHost(host.serverId); - return; + return true; } - if (options.length === 0) return; + if (options.length === 0) return false; event.preventDefault(); const next = event.key === "ArrowDown" ? activeOptionIndex + 1 : activeOptionIndex - 1; if (next < 0) { setActiveIndex(options.length - 1); - return; + return true; } if (next >= options.length) { setActiveIndex(0); - return; + return true; } setActiveIndex(next); - }; - - window.addEventListener("keydown", handleKeyDown, true); - return () => window.removeEventListener("keydown", handleKeyDown, true); - }, [activeOptionIndex, chooseHost, close, options, request]); + return true; + }, + [activeOptionIndex, chooseHost, close, options], + ); + const setWebOverlayScope = useWebOverlayRegistration({ + active: isWeb && request != null, + layer: modalLayer, + onKeyDown: handleWebOverlayKeyDown, + }); if (!request) return null; - return ( + const modal = ( - + {request.title} ); + + return createElement(OverlayLayerProvider, { layer: isWeb ? modalLayer : 0 }, modal); } const styles = StyleSheet.create((theme) => ({ diff --git a/packages/app/src/lib/overlay-root.ts b/packages/app/src/lib/overlay-root.ts index 95b4e4d2c..9b19b8825 100644 --- a/packages/app/src/lib/overlay-root.ts +++ b/packages/app/src/lib/overlay-root.ts @@ -1,11 +1,27 @@ +import { + createContext, + createElement, + useCallback, + useContext, + useLayoutEffect, + useMemo, + useRef, + type ReactNode, +} from "react"; + /** * Shared overlay root for web portals (modals, toasts, etc.) * This ensures consistent stacking order by controlling a single overlay container. * * Z-index scale within overlay root: - * - Modal backdrop/content: 10 - * - Toast: 20 - * - Tooltip: 30 + * - Floating panel: parent layer + 10 + * - Modal: parent layer + 20 + * - Toast: 10,000 + * - Tooltip: 20,000 + * + * Floating panels and modals provide their resolved layer to descendants. A + * dropdown opened inside a modal therefore paints above that modal, while a + * base dropdown remains below a modal opened over it. */ export function getOverlayRoot(): HTMLElement { let el = document.getElementById("overlay-root"); @@ -21,7 +37,240 @@ export function getOverlayRoot(): HTMLElement { } export const OVERLAY_Z = { - modal: 10, - toast: 20, - tooltip: 30, + floating: 10, + modal: 20, + toast: 10_000, + tooltip: 20_000, } as const; + +type OverlayKind = "floating" | "modal"; + +const OverlayLayerContext = createContext(0); + +export function useOverlayLayer(kind: OverlayKind): number { + return useContext(OverlayLayerContext) + OVERLAY_Z[kind]; +} + +/** + * Resolves a globally hosted web overlay above whichever registered overlay + * was topmost when it opened. Global hosts live outside their opener's React + * tree, so context alone cannot preserve that relative ownership. + */ +export function useGlobalWebOverlayLayer(kind: OverlayKind, active: boolean): number { + const contextualLayer = useOverlayLayer(kind); + return useMemo(() => { + if (!active) return contextualLayer; + const topLayer = getTopWebOverlay()?.getLayer() ?? 0; + return Math.max(contextualLayer, topLayer + OVERLAY_Z[kind]); + }, [active, contextualLayer, kind]); +} + +export function useCurrentOverlayLayer(): number { + return useContext(OverlayLayerContext); +} + +export function OverlayLayerProvider({ layer, children }: { layer: number; children?: ReactNode }) { + return createElement(OverlayLayerContext.Provider, { value: layer }, children); +} + +type WebOverlayKeyHandler = (event: KeyboardEvent) => boolean; + +interface WebOverlayEntry { + id: symbol; + order: number; + getLayer: () => number; + getScope: () => HTMLElement | null; + getKeyHandler: () => WebOverlayKeyHandler; + restoreFocus: HTMLElement | null; +} + +const webOverlayEntries: WebOverlayEntry[] = []; +let webOverlayOrder = 0; +let webOverlayListenersAttached = false; +let webOverlayFocusCheckQueued = false; + +function getTopWebOverlay(): WebOverlayEntry | undefined { + return webOverlayEntries.reduce((top, entry) => { + if (!top) return entry; + const layerDifference = entry.getLayer() - top.getLayer(); + return layerDifference > 0 || (layerDifference === 0 && entry.order > top.order) ? entry : top; + }, undefined); +} + +function getFocusableElements(scope: HTMLElement): HTMLElement[] { + const selector = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + '[tabindex]:not([tabindex="-1"])', + ].join(","); + + return Array.from(scope.querySelectorAll(selector)).filter( + (element) => + element.getAttribute("aria-hidden") !== "true" && + !element.hasAttribute("hidden") && + element.getClientRects().length > 0, + ); +} + +function focusFirstElement(scope: HTMLElement): void { + const first = getFocusableElements(scope)[0]; + (first ?? scope).focus(); +} + +function handleWebOverlayFocus(event: FocusEvent): void { + const top = getTopWebOverlay(); + const scope = top?.getScope(); + if (!scope || scope.contains(event.target as Node)) return; + if (webOverlayFocusCheckQueued) return; + + // React can autofocus a child before its parent scope ref attaches. Defer + // enforcement until the commit finishes so a newly mounted higher overlay + // can register without the previous scope stealing the requested focus. + webOverlayFocusCheckQueued = true; + queueMicrotask(() => { + webOverlayFocusCheckQueued = false; + const currentScope = getTopWebOverlay()?.getScope(); + if (!currentScope || currentScope.contains(document.activeElement)) return; + focusFirstElement(currentScope); + }); +} + +function handleWebOverlayKeyDown(event: KeyboardEvent): void { + const top = getTopWebOverlay(); + const scope = top?.getScope(); + if (!top || !scope) return; + + if (event.key === "Tab") { + const focusable = getFocusableElements(scope); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement; + const shouldWrapBackward = event.shiftKey && (!scope.contains(active) || active === first); + const shouldWrapForward = !event.shiftKey && (!scope.contains(active) || active === last); + if (shouldWrapBackward || shouldWrapForward) { + event.preventDefault(); + event.stopImmediatePropagation(); + (shouldWrapBackward ? last : first)?.focus(); + if (!first) scope.focus(); + return; + } + } + + if (!top.getKeyHandler()(event)) return; + event.stopImmediatePropagation(); +} + +function attachWebOverlayListeners(): void { + if (webOverlayListenersAttached) return; + window.addEventListener("keydown", handleWebOverlayKeyDown, true); + document.addEventListener("focusin", handleWebOverlayFocus, true); + webOverlayListenersAttached = true; +} + +function detachWebOverlayListeners(): void { + if (!webOverlayListenersAttached || webOverlayEntries.length > 0) return; + window.removeEventListener("keydown", handleWebOverlayKeyDown, true); + document.removeEventListener("focusin", handleWebOverlayFocus, true); + webOverlayListenersAttached = false; +} + +function addWebOverlay(entry: WebOverlayEntry): () => void { + webOverlayEntries.push(entry); + attachWebOverlayListeners(); + + const focusFrame = window.requestAnimationFrame(() => { + const scope = entry.getScope(); + if (getTopWebOverlay() === entry && scope && !scope.contains(document.activeElement)) { + focusFirstElement(scope); + } + }); + + return () => { + window.cancelAnimationFrame(focusFrame); + const index = webOverlayEntries.findIndex((candidate) => candidate.id === entry.id); + if (index !== -1) webOverlayEntries.splice(index, 1); + detachWebOverlayListeners(); + if (entry.restoreFocus && document.contains(entry.restoreFocus)) { + entry.restoreFocus.focus(); + } + }; +} + +interface WebOverlayRegistration { + active: boolean; + layer: number; + onKeyDown: WebOverlayKeyHandler; +} + +/** + * Registers a focus scope in the same relative layer model used for painting. + * Only the highest painted overlay receives keyboard input; focus is trapped + * there and restored to the opener when that overlay closes. + */ +export function useWebOverlayRegistration({ active, layer, onKeyDown }: WebOverlayRegistration) { + const idRef = useRef(Symbol("web-overlay")); + const scopeRef = useRef(null); + const layerRef = useRef(layer); + const keyHandlerRef = useRef(onKeyDown); + const restoreFocusRef = useRef(null); + const removeEntryRef = useRef<(() => void) | null>(null); + const activeRef = useRef(active); + const wasActiveRef = useRef(false); + + activeRef.current = active; + layerRef.current = layer; + keyHandlerRef.current = onKeyDown; + if (active && !wasActiveRef.current && typeof document !== "undefined") { + restoreFocusRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + } + wasActiveRef.current = active; + + const syncRegistration = useCallback(() => { + const shouldRegister = + activeRef.current && + scopeRef.current != null && + typeof window !== "undefined" && + typeof document !== "undefined"; + if (!shouldRegister) { + removeEntryRef.current?.(); + removeEntryRef.current = null; + return; + } + if (removeEntryRef.current) return; + + const entry: WebOverlayEntry = { + id: idRef.current, + order: ++webOverlayOrder, + getLayer: () => layerRef.current, + getScope: () => scopeRef.current, + getKeyHandler: () => keyHandlerRef.current, + restoreFocus: restoreFocusRef.current, + }; + removeEntryRef.current = addWebOverlay(entry); + }, []); + + const setScope = useCallback( + (node: unknown) => { + scopeRef.current = + typeof HTMLElement !== "undefined" && node instanceof HTMLElement ? node : null; + // Host refs attach before descendant layout effects and autofocus. Register + // here so the previous overlay cannot redirect that pending focus. + syncRegistration(); + }, + [syncRegistration], + ); + + useLayoutEffect(() => { + syncRegistration(); + return () => { + removeEntryRef.current?.(); + removeEntryRef.current = null; + }; + }, [active, syncRegistration]); + + return setScope; +} diff --git a/packages/app/src/stores/provider-settings-store.test.ts b/packages/app/src/stores/provider-settings-store.test.ts new file mode 100644 index 000000000..ca3dc41e7 --- /dev/null +++ b/packages/app/src/stores/provider-settings-store.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { useProviderSettingsStore } from "./provider-settings-store"; + +describe("provider settings store", () => { + afterEach(() => { + useProviderSettingsStore.setState({ + serverId: null, + provider: null, + overlayParentLayer: 0, + visible: false, + }); + }); + + it("carries the opener layer without leaking it into later base-level opens", () => { + useProviderSettingsStore.getState().open({ + serverId: "server-1", + provider: "codex", + overlayParentLayer: 30, + }); + expect(useProviderSettingsStore.getState().overlayParentLayer).toBe(30); + + useProviderSettingsStore.getState().open({ + serverId: "server-1", + provider: "claude", + }); + expect(useProviderSettingsStore.getState().overlayParentLayer).toBe(0); + }); +}); diff --git a/packages/app/src/stores/provider-settings-store.ts b/packages/app/src/stores/provider-settings-store.ts index abd214c5a..4529f7e3e 100644 --- a/packages/app/src/stores/provider-settings-store.ts +++ b/packages/app/src/stores/provider-settings-store.ts @@ -3,11 +3,13 @@ import { create } from "zustand"; interface ProviderSettingsTarget { serverId: string; provider: string; + overlayParentLayer?: number; } interface ProviderSettingsStoreState { serverId: string | null; provider: string | null; + overlayParentLayer: number; visible: boolean; open: (target: ProviderSettingsTarget) => void; close: () => void; @@ -16,9 +18,10 @@ interface ProviderSettingsStoreState { export const useProviderSettingsStore = create()((set) => ({ serverId: null, provider: null, + overlayParentLayer: 0, visible: false, - open: ({ serverId, provider }) => { - set({ serverId, provider, visible: true }); + open: ({ serverId, provider, overlayParentLayer = 0 }) => { + set({ serverId, provider, overlayParentLayer, visible: true }); }, close: () => { set({ visible: false });