mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Keep provider settings above the model selector (#2476)
* fix(app): keep provider settings above model selector Desktop web comboboxes used the browser top layer, so ordinary modal portals could not cover them. Keep web overlays in one relative layer stack while preserving native bottom-sheet stacking. * fix(app): route overlay keyboard ownership * fix(app): preserve nested overlay ownership * fix(app): route command center overlay input * fix(app): preserve global overlay ancestry * fix(app): register global dialog hosts
This commit is contained in:
@@ -100,11 +100,7 @@ async function selectMode(page: Page, label: string): Promise<void> {
|
||||
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 });
|
||||
|
||||
@@ -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<boolean> {
|
||||
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,
|
||||
}) => {
|
||||
|
||||
@@ -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<FlatList<CommandCenterListRow>>(null);
|
||||
const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null);
|
||||
const bottomSheetInputRef = useRef<React.ElementRef<typeof BottomSheetTextInput>>(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<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||
@@ -663,27 +673,29 @@ export function CommandCenter() {
|
||||
}
|
||||
if (!state.open) return null;
|
||||
return (
|
||||
<Modal visible transparent animationType="fade" onRequestClose={state.close}>
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.backdrop} onPress={state.close} />
|
||||
<View testID="command-center-panel" style={styles.panel}>
|
||||
<View style={styles.header}>
|
||||
<ThemedTextInput
|
||||
testID="command-center-input"
|
||||
ref={state.inputRef}
|
||||
value={state.query}
|
||||
onChangeText={state.setQuery}
|
||||
placeholder={t("shell.commandCenter.placeholder")}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
<OverlayLayerProvider layer={isWeb ? modalLayer : 0}>
|
||||
<Modal visible transparent animationType="fade" onRequestClose={state.close}>
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.backdrop} onPress={state.close} />
|
||||
<View ref={setWebOverlayScope} testID="command-center-panel" style={styles.panel}>
|
||||
<View style={styles.header}>
|
||||
<ThemedTextInput
|
||||
testID="command-center-input"
|
||||
ref={state.inputRef}
|
||||
value={state.query}
|
||||
onChangeText={state.setQuery}
|
||||
placeholder={t("shell.commandCenter.placeholder")}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
</View>
|
||||
<FlatList ref={listRef} style={styles.results} {...commonListProps} />
|
||||
</View>
|
||||
<FlatList ref={listRef} style={styles.results} {...commonListProps} />
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</Modal>
|
||||
</OverlayLayerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
<>
|
||||
<OverlayLayerProvider layer={modalLayer}>
|
||||
<SheetHeaderView header={header} onClose={onClose} />
|
||||
{scrollable ? (
|
||||
<View style={styles.desktopScrollContainer}>
|
||||
@@ -717,7 +706,7 @@ export function AdaptiveModalSheet({
|
||||
<View style={[styles.desktopStaticContent, contentContainerStyle]}>{children}</View>
|
||||
)}
|
||||
{footer ? <View style={footerStyle}>{footer}</View> : null}
|
||||
</>
|
||||
</OverlayLayerProvider>
|
||||
);
|
||||
|
||||
const desktopContent = (
|
||||
@@ -727,7 +716,15 @@ export function AdaptiveModalSheet({
|
||||
style={ABSOLUTE_FILL_STYLE}
|
||||
onPress={onClose}
|
||||
/>
|
||||
<View style={desktopCardStyle}>{cardInner}</View>
|
||||
<View
|
||||
ref={setWebOverlayScope}
|
||||
style={desktopCardStyle}
|
||||
role="dialog"
|
||||
aria-modal
|
||||
tabIndex={-1}
|
||||
>
|
||||
{cardInner}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
<Modal visible transparent animationType="fade" onRequestClose={isWeb ? undefined : handleBack}>
|
||||
<View style={styles.overlay} testID="add-project-flow">
|
||||
<Pressable style={styles.backdrop} onPress={onClose} testID="add-project-flow-backdrop" />
|
||||
<View
|
||||
ref={setWebOverlayScope}
|
||||
style={styles.panel}
|
||||
testID={`add-project-flow-page-${page.kind}`}
|
||||
accessibilityLabel={`Add project: ${page.kind}`}
|
||||
@@ -913,6 +933,8 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
return createElement(OverlayLayerProvider, { layer: isWeb ? modalLayer : 0 }, modal);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
|
||||
@@ -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 (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
disabled={!serverId}
|
||||
hitSlop={8}
|
||||
style={iconButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={`selector-header-settings-${provider}`}
|
||||
>
|
||||
<HeaderSettingsIcon disabled={!serverId} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const IndependentScrollGestureContext = createContext<ReturnType<typeof Gesture.Native> | 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<SheetHeader>(() => {
|
||||
if (view.kind === "all") {
|
||||
@@ -254,19 +280,13 @@ export function useModelBrowser({
|
||||
),
|
||||
back: singleProviderView ? undefined : { onPress: handleBackToAll },
|
||||
actions: (
|
||||
<Pressable
|
||||
onPress={openProviderSettings}
|
||||
disabled={!serverId}
|
||||
hitSlop={8}
|
||||
style={iconButtonStyle}
|
||||
accessibilityRole="button"
|
||||
<ProviderSettingsAction
|
||||
serverId={serverId}
|
||||
provider={view.providerId}
|
||||
accessibilityLabel={t("modelSelector.openProviderSettings", {
|
||||
provider: view.providerLabel,
|
||||
})}
|
||||
testID={`selector-header-settings-${view.providerId}`}
|
||||
>
|
||||
<HeaderSettingsIcon disabled={!serverId} />
|
||||
</Pressable>
|
||||
/>
|
||||
),
|
||||
search: {
|
||||
onChange: handleSearchQueryChange,
|
||||
@@ -279,7 +299,6 @@ export function useModelBrowser({
|
||||
}, [
|
||||
handleBackToAll,
|
||||
handleSearchQueryChange,
|
||||
openProviderSettings,
|
||||
searchResetKey,
|
||||
serverId,
|
||||
singleProviderView,
|
||||
|
||||
@@ -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 (
|
||||
<ProviderDiagnosticSheet
|
||||
key={`${serverId}:${provider}`}
|
||||
provider={provider}
|
||||
serverId={serverId}
|
||||
visible={visible}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
<OverlayLayerProvider layer={overlayParentLayer}>
|
||||
<ProviderDiagnosticSheet
|
||||
key={`${serverId}:${provider}`}
|
||||
provider={provider}
|
||||
serverId={serverId}
|
||||
visible={visible}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
</OverlayLayerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<typeof useFloating>["refs"];
|
||||
shouldUseDesktopFade: boolean;
|
||||
desktopFrameStyle: StyleProp<ViewStyle>;
|
||||
@@ -1166,14 +1161,38 @@ function DesktopComboboxOptionsBody(props: {
|
||||
}
|
||||
|
||||
function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
|
||||
return (
|
||||
<Modal
|
||||
transparent
|
||||
animationType="none"
|
||||
visible={props.isOpen}
|
||||
onRequestClose={props.handleClose}
|
||||
>
|
||||
<View ref={props.refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}>
|
||||
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 = (
|
||||
<OverlayLayerProvider layer={props.overlayLayer}>
|
||||
<View
|
||||
ref={props.refs.setOffsetParent}
|
||||
collapsable={false}
|
||||
style={[
|
||||
styles.desktopOverlay,
|
||||
isWeb ? styles.desktopOverlayWeb : null,
|
||||
isWeb ? { zIndex: props.overlayLayer } : null,
|
||||
]}
|
||||
>
|
||||
<Pressable style={styles.desktopBackdrop} onPress={props.handleClose} />
|
||||
<FloatingSurface
|
||||
testID="combobox-desktop-container"
|
||||
@@ -1181,8 +1200,11 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
|
||||
exiting={props.shouldUseDesktopFade ? FadeOut.duration(100) : undefined}
|
||||
style={styles.desktopContainer}
|
||||
frameStyle={props.desktopFrameStyle}
|
||||
ref={props.refs.setFloating}
|
||||
ref={setFloatingRef}
|
||||
collapsable={false}
|
||||
role="dialog"
|
||||
aria-modal
|
||||
tabIndex={-1}
|
||||
onLayout={props.handleDesktopContentLayout}
|
||||
>
|
||||
{props.hasChildren ? (
|
||||
@@ -1215,6 +1237,21 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
|
||||
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null}
|
||||
</FloatingSurface>
|
||||
</View>
|
||||
</OverlayLayerProvider>
|
||||
);
|
||||
|
||||
if (isWeb && typeof document !== "undefined") {
|
||||
return createPortal(overlay, getOverlayRoot());
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
transparent
|
||||
animationType="none"
|
||||
visible={props.isOpen}
|
||||
onRequestClose={props.handleClose}
|
||||
>
|
||||
{overlay}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<DesktopComboboxBody
|
||||
overlayLayer={floatingLayer}
|
||||
isOpen={isOpen}
|
||||
handleClose={handleClose}
|
||||
handleDesktopKey={handleDesktopKey}
|
||||
refs={refs}
|
||||
shouldUseDesktopFade={shouldUseDesktopFade}
|
||||
desktopFrameStyle={desktopFrameStyle}
|
||||
@@ -1707,6 +1746,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
desktopOverlay: {
|
||||
flex: 1,
|
||||
},
|
||||
desktopOverlayWeb: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
pointerEvents: "auto" as const,
|
||||
},
|
||||
desktopBackdrop: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
|
||||
@@ -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 (
|
||||
<FloatingSurface
|
||||
ref={scopeRef}
|
||||
collapsable={false}
|
||||
tabIndex={-1}
|
||||
nativeID={surfaceNativeID}
|
||||
testID={testID}
|
||||
style={surfaceStyle}
|
||||
@@ -449,6 +458,7 @@ export function DropdownMenuContent({
|
||||
testID?: string;
|
||||
}>): 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 = (
|
||||
<View style={[styles.overlay, isWeb ? styles.overlayWeb : null]}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("menu.backdrop")}
|
||||
style={styles.backdrop}
|
||||
onPress={handleClose}
|
||||
testID={testID ? `${testID}-backdrop` : undefined}
|
||||
/>
|
||||
{!closing
|
||||
? renderDropdownSurface({
|
||||
frameStyle,
|
||||
testID,
|
||||
surfaceStyle,
|
||||
scrollable,
|
||||
scrollViewportStyle,
|
||||
content,
|
||||
surfaceNativeID,
|
||||
onExited: () => setModalVisible(false),
|
||||
})
|
||||
: null}
|
||||
</View>
|
||||
<OverlayLayerProvider layer={floatingLayer}>
|
||||
<View
|
||||
style={[
|
||||
styles.overlay,
|
||||
isWeb ? styles.overlayWeb : null,
|
||||
isWeb ? { zIndex: floatingLayer } : null,
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("menu.backdrop")}
|
||||
style={styles.backdrop}
|
||||
onPress={handleClose}
|
||||
testID={testID ? `${testID}-backdrop` : undefined}
|
||||
/>
|
||||
{!closing
|
||||
? renderDropdownSurface({
|
||||
frameStyle,
|
||||
testID,
|
||||
surfaceStyle,
|
||||
scrollable,
|
||||
scrollViewportStyle,
|
||||
content,
|
||||
surfaceNativeID,
|
||||
scopeRef: setWebOverlayScope,
|
||||
onExited: () => setModalVisible(false),
|
||||
})
|
||||
: null}
|
||||
</View>
|
||||
</OverlayLayerProvider>
|
||||
);
|
||||
|
||||
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: {
|
||||
|
||||
@@ -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<TextInput>(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 = (
|
||||
<Modal visible transparent animationType="fade" onRequestClose={close} testID="host-chooser">
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.backdrop} onPress={close} />
|
||||
<View style={styles.panel}>
|
||||
<View ref={setWebOverlayScope} style={styles.panel}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{request.title}</Text>
|
||||
<TextInput
|
||||
@@ -268,6 +276,8 @@ export function HostChooserModal() {
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
return createElement(OverlayLayerProvider, { layer: isWeb ? modalLayer : 0 }, modal);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
|
||||
@@ -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<WebOverlayEntry | undefined>((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<HTMLElement>(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<HTMLElement | null>(null);
|
||||
const layerRef = useRef(layer);
|
||||
const keyHandlerRef = useRef(onKeyDown);
|
||||
const restoreFocusRef = useRef<HTMLElement | null>(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;
|
||||
}
|
||||
|
||||
28
packages/app/src/stores/provider-settings-store.test.ts
Normal file
28
packages/app/src/stores/provider-settings-store.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<ProviderSettingsStoreState>()((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 });
|
||||
|
||||
Reference in New Issue
Block a user