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:
@@ -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
|
tooltips behind the menu. The shared overlay scale keeps menus below toasts and
|
||||||
lets tooltip portals paint above both.
|
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 `<Modal>` 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
|
## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
|
||||||
|
|
||||||
A Portal escapes Android's hit-test, but it also escapes two things you were
|
A Portal escapes Android's hit-test, but it also escapes two things you were
|
||||||
|
|||||||
@@ -100,11 +100,7 @@ async function selectMode(page: Page, label: string): Promise<void> {
|
|||||||
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||||||
await searchInput.fill(label);
|
await searchInput.fill(label);
|
||||||
|
|
||||||
const option = page
|
const option = popup.getByText(new RegExp(`^${escapeRegex(label)}$`, "i")).first();
|
||||||
.getByRole("dialog")
|
|
||||||
.last()
|
|
||||||
.getByText(new RegExp(`^${escapeRegex(label)}$`, "i"))
|
|
||||||
.first();
|
|
||||||
await expect(option).toBeVisible({ timeout: 10_000 });
|
await expect(option).toBeVisible({ timeout: 10_000 });
|
||||||
await option.click({ force: true });
|
await option.click({ force: true });
|
||||||
await expect(searchInput).not.toBeVisible({ timeout: 5_000 });
|
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 { expect, test, type Page } from "./fixtures";
|
||||||
import { expectComposerVisible } from "./helpers/composer";
|
import { expectComposerVisible } from "./helpers/composer";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
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 });
|
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) {
|
async function expectProviderSettingsVisible(page: Page) {
|
||||||
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
|
||||||
await expect(page.getByRole("button", { name: "Add model" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Add model" })).toBeVisible();
|
||||||
@@ -89,7 +117,69 @@ async function exerciseProviderSettingsStack(page: Page) {
|
|||||||
await expectProviderSettingsVisible(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 ({
|
test("provider settings and children close back through the model selector stack", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregate
|
|||||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||||
import { useProjects } from "@/hooks/use-projects";
|
import { useProjects } from "@/hooks/use-projects";
|
||||||
|
import {
|
||||||
|
OverlayLayerProvider,
|
||||||
|
useGlobalWebOverlayLayer,
|
||||||
|
useWebOverlayRegistration,
|
||||||
|
} from "@/lib/overlay-root";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||||
@@ -372,15 +377,6 @@ function useCommandCenterState(): CommandCenterState {
|
|||||||
return cancel;
|
return cancel;
|
||||||
}, [open]);
|
}, [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 {
|
return {
|
||||||
open,
|
open,
|
||||||
query,
|
query,
|
||||||
@@ -553,6 +549,7 @@ export function CommandCenter() {
|
|||||||
const state = useCommandCenterState();
|
const state = useCommandCenterState();
|
||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
const showBottomSheet = isCompact && isNative;
|
const showBottomSheet = isCompact && isNative;
|
||||||
|
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
|
||||||
const listRef = useRef<FlatList<CommandCenterListRow>>(null);
|
const listRef = useRef<FlatList<CommandCenterListRow>>(null);
|
||||||
const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null);
|
const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null);
|
||||||
const bottomSheetInputRef = useRef<React.ElementRef<typeof BottomSheetTextInput>>(null);
|
const bottomSheetInputRef = useRef<React.ElementRef<typeof BottomSheetTextInput>>(null);
|
||||||
@@ -618,6 +615,19 @@ export function CommandCenter() {
|
|||||||
[state],
|
[state],
|
||||||
);
|
);
|
||||||
const submit = useCallback(() => state.key("Enter"), [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(
|
const backdrop = useCallback(
|
||||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||||
@@ -663,27 +673,29 @@ export function CommandCenter() {
|
|||||||
}
|
}
|
||||||
if (!state.open) return null;
|
if (!state.open) return null;
|
||||||
return (
|
return (
|
||||||
<Modal visible transparent animationType="fade" onRequestClose={state.close}>
|
<OverlayLayerProvider layer={isWeb ? modalLayer : 0}>
|
||||||
<View style={styles.overlay}>
|
<Modal visible transparent animationType="fade" onRequestClose={state.close}>
|
||||||
<Pressable style={styles.backdrop} onPress={state.close} />
|
<View style={styles.overlay}>
|
||||||
<View testID="command-center-panel" style={styles.panel}>
|
<Pressable style={styles.backdrop} onPress={state.close} />
|
||||||
<View style={styles.header}>
|
<View ref={setWebOverlayScope} testID="command-center-panel" style={styles.panel}>
|
||||||
<ThemedTextInput
|
<View style={styles.header}>
|
||||||
testID="command-center-input"
|
<ThemedTextInput
|
||||||
ref={state.inputRef}
|
testID="command-center-input"
|
||||||
value={state.query}
|
ref={state.inputRef}
|
||||||
onChangeText={state.setQuery}
|
value={state.query}
|
||||||
placeholder={t("shell.commandCenter.placeholder")}
|
onChangeText={state.setQuery}
|
||||||
style={styles.input}
|
placeholder={t("shell.commandCenter.placeholder")}
|
||||||
autoCapitalize="none"
|
style={styles.input}
|
||||||
autoCorrect={false}
|
autoCapitalize="none"
|
||||||
autoFocus
|
autoCorrect={false}
|
||||||
/>
|
autoFocus
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<FlatList ref={listRef} style={styles.results} {...commonListProps} />
|
||||||
</View>
|
</View>
|
||||||
<FlatList ref={listRef} style={styles.results} {...commonListProps} />
|
|
||||||
</View>
|
</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 type { StyleProp, TextInputProps, ViewStyle } from "react-native";
|
||||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
import {
|
||||||
|
getOverlayRoot,
|
||||||
|
OverlayLayerProvider,
|
||||||
|
useGlobalWebOverlayLayer,
|
||||||
|
useWebOverlayRegistration,
|
||||||
|
} from "../lib/overlay-root";
|
||||||
import {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
BottomSheetScrollView,
|
BottomSheetScrollView,
|
||||||
@@ -54,36 +59,8 @@ export interface SheetHeader {
|
|||||||
search?: SheetHeaderSearch;
|
search?: SheetHeaderSearch;
|
||||||
}
|
}
|
||||||
|
|
||||||
type EscHandler = () => void;
|
|
||||||
const escStack: EscHandler[] = [];
|
|
||||||
let escListenerAttached = false;
|
|
||||||
const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFillObject };
|
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) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
desktopOverlay: {
|
desktopOverlay: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...StyleSheet.absoluteFillObject,
|
||||||
@@ -91,7 +68,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
padding: theme.spacing[6],
|
padding: theme.spacing[6],
|
||||||
zIndex: OVERLAY_Z.modal,
|
|
||||||
pointerEvents: "auto" as const,
|
pointerEvents: "auto" as const,
|
||||||
},
|
},
|
||||||
desktopCard: {
|
desktopCard: {
|
||||||
@@ -582,6 +558,7 @@ export function AdaptiveModalSheet({
|
|||||||
});
|
});
|
||||||
const [shouldRenderWeb, setShouldRenderWeb] = useState(visible);
|
const [shouldRenderWeb, setShouldRenderWeb] = useState(visible);
|
||||||
const [isWebClosing, setIsWebClosing] = useState(false);
|
const [isWebClosing, setIsWebClosing] = useState(false);
|
||||||
|
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && !isMobile && shouldRenderWeb);
|
||||||
const nativeModalDismissNotifiedRef = useRef(!visible);
|
const nativeModalDismissNotifiedRef = useRef(!visible);
|
||||||
const handleDismiss = useCallback(() => {
|
const handleDismiss = useCallback(() => {
|
||||||
handleSheetDismiss();
|
handleSheetDismiss();
|
||||||
@@ -610,19 +587,31 @@ export function AdaptiveModalSheet({
|
|||||||
() => [
|
() => [
|
||||||
styles.desktopOverlay,
|
styles.desktopOverlay,
|
||||||
isWeb && {
|
isWeb && {
|
||||||
|
zIndex: modalLayer,
|
||||||
opacity: isWebClosing ? 0 : 1,
|
opacity: isWebClosing ? 0 : 1,
|
||||||
transitionDuration: `${WEB_EXIT_DURATION_MS}ms`,
|
transitionDuration: `${WEB_EXIT_DURATION_MS}ms`,
|
||||||
transitionProperty: "opacity",
|
transitionProperty: "opacity",
|
||||||
transitionTimingFunction: "ease",
|
transitionTimingFunction: "ease",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[isWebClosing],
|
[isWebClosing, modalLayer],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleWebOverlayKeyDown = useCallback(
|
||||||
if (!isWeb || isMobile || !visible) return;
|
(event: KeyboardEvent) => {
|
||||||
return pushEscHandler(onClose);
|
if (event.key !== "Escape") return false;
|
||||||
}, [visible, isMobile, onClose]);
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[onClose],
|
||||||
|
);
|
||||||
|
const setWebOverlayScope = useWebOverlayRegistration({
|
||||||
|
active: isWeb && !isMobile && visible,
|
||||||
|
layer: modalLayer,
|
||||||
|
onKeyDown: handleWebOverlayKeyDown,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
@@ -700,7 +689,7 @@ export function AdaptiveModalSheet({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cardInner = (
|
const cardInner = (
|
||||||
<>
|
<OverlayLayerProvider layer={modalLayer}>
|
||||||
<SheetHeaderView header={header} onClose={onClose} />
|
<SheetHeaderView header={header} onClose={onClose} />
|
||||||
{scrollable ? (
|
{scrollable ? (
|
||||||
<View style={styles.desktopScrollContainer}>
|
<View style={styles.desktopScrollContainer}>
|
||||||
@@ -717,7 +706,7 @@ export function AdaptiveModalSheet({
|
|||||||
<View style={[styles.desktopStaticContent, contentContainerStyle]}>{children}</View>
|
<View style={[styles.desktopStaticContent, contentContainerStyle]}>{children}</View>
|
||||||
)}
|
)}
|
||||||
{footer ? <View style={footerStyle}>{footer}</View> : null}
|
{footer ? <View style={footerStyle}>{footer}</View> : null}
|
||||||
</>
|
</OverlayLayerProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
const desktopContent = (
|
const desktopContent = (
|
||||||
@@ -727,7 +716,15 @@ export function AdaptiveModalSheet({
|
|||||||
style={ABSOLUTE_FILL_STYLE}
|
style={ABSOLUTE_FILL_STYLE}
|
||||||
onPress={onClose}
|
onPress={onClose}
|
||||||
/>
|
/>
|
||||||
<View style={desktopCardStyle}>{cardInner}</View>
|
<View
|
||||||
|
ref={setWebOverlayScope}
|
||||||
|
style={desktopCardStyle}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{cardInner}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,15 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Server,
|
Server,
|
||||||
} from "lucide-react-native";
|
} 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 {
|
import {
|
||||||
Modal,
|
Modal,
|
||||||
Pressable,
|
Pressable,
|
||||||
@@ -66,6 +74,11 @@ import { useFetchQuery } from "@/data/query";
|
|||||||
import { getOpenProjectFailureReason, registerProjectDescriptor } from "@/hooks/open-project";
|
import { getOpenProjectFailureReason, registerProjectDescriptor } from "@/hooks/open-project";
|
||||||
import { useIsLocalDaemon, useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
import { useIsLocalDaemon, useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||||
import { useCloneGithubProject, useOpenProject } from "@/hooks/use-open-project";
|
import { useCloneGithubProject, useOpenProject } from "@/hooks/use-open-project";
|
||||||
|
import {
|
||||||
|
OverlayLayerProvider,
|
||||||
|
useGlobalWebOverlayLayer,
|
||||||
|
useWebOverlayRegistration,
|
||||||
|
} from "@/lib/overlay-root";
|
||||||
import {
|
import {
|
||||||
useHosts,
|
useHosts,
|
||||||
useHostRuntimeClient,
|
useHostRuntimeClient,
|
||||||
@@ -769,14 +782,20 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
|||||||
[activeIndex, handleBack, rows, submitActive],
|
[activeIndex, handleBack, rows, submitActive],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb);
|
||||||
if (!isWeb || typeof window === "undefined") return;
|
const handleWebOverlayKeyDown = useCallback(
|
||||||
const listener = (event: KeyboardEvent) => {
|
(event: KeyboardEvent) => {
|
||||||
if (handleKey(event.key)) event.preventDefault();
|
if (!handleKey(event.key)) return false;
|
||||||
};
|
event.preventDefault();
|
||||||
window.addEventListener("keydown", listener, true);
|
return true;
|
||||||
return () => window.removeEventListener("keydown", listener, true);
|
},
|
||||||
}, [handleKey]);
|
[handleKey],
|
||||||
|
);
|
||||||
|
const setWebOverlayScope = useWebOverlayRegistration({
|
||||||
|
active: isWeb,
|
||||||
|
layer: modalLayer,
|
||||||
|
onKeyDown: handleWebOverlayKeyDown,
|
||||||
|
});
|
||||||
|
|
||||||
const handleNativeKeyPress = useCallback(
|
const handleNativeKeyPress = useCallback(
|
||||||
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => {
|
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => {
|
||||||
@@ -816,11 +835,12 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
|||||||
? joinDirectoryPath(page.parentPath, page.name.trim())
|
? joinDirectoryPath(page.parentPath, page.name.trim())
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
const modal = (
|
||||||
<Modal visible transparent animationType="fade" onRequestClose={isWeb ? undefined : handleBack}>
|
<Modal visible transparent animationType="fade" onRequestClose={isWeb ? undefined : handleBack}>
|
||||||
<View style={styles.overlay} testID="add-project-flow">
|
<View style={styles.overlay} testID="add-project-flow">
|
||||||
<Pressable style={styles.backdrop} onPress={onClose} testID="add-project-flow-backdrop" />
|
<Pressable style={styles.backdrop} onPress={onClose} testID="add-project-flow-backdrop" />
|
||||||
<View
|
<View
|
||||||
|
ref={setWebOverlayScope}
|
||||||
style={styles.panel}
|
style={styles.panel}
|
||||||
testID={`add-project-flow-page-${page.kind}`}
|
testID={`add-project-flow-page-${page.kind}`}
|
||||||
accessibilityLabel={`Add project: ${page.kind}`}
|
accessibilityLabel={`Add project: ${page.kind}`}
|
||||||
@@ -913,6 +933,8 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
|||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return createElement(OverlayLayerProvider, { layer: isWeb ? modalLayer : 0 }, modal);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
type ProviderSelectorProvider,
|
type ProviderSelectorProvider,
|
||||||
} from "@/provider-selection/provider-selection";
|
} from "@/provider-selection/provider-selection";
|
||||||
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
||||||
|
import { useCurrentOverlayLayer } from "@/lib/overlay-root";
|
||||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
import {
|
import {
|
||||||
resolveInitialModelBrowserView,
|
resolveInitialModelBrowserView,
|
||||||
@@ -53,6 +54,36 @@ const ThemedSearch = withUnistyles(Search);
|
|||||||
const ThemedSettings = withUnistyles(Settings);
|
const ThemedSettings = withUnistyles(Settings);
|
||||||
const ThemedStar = withUnistyles(Star);
|
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>(
|
const IndependentScrollGestureContext = createContext<ReturnType<typeof Gesture.Native> | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -237,11 +268,6 @@ export function useModelBrowser({
|
|||||||
setSearchQuery(value);
|
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 singleProviderView = providers.length === 1;
|
||||||
const header = useMemo<SheetHeader>(() => {
|
const header = useMemo<SheetHeader>(() => {
|
||||||
if (view.kind === "all") {
|
if (view.kind === "all") {
|
||||||
@@ -254,19 +280,13 @@ export function useModelBrowser({
|
|||||||
),
|
),
|
||||||
back: singleProviderView ? undefined : { onPress: handleBackToAll },
|
back: singleProviderView ? undefined : { onPress: handleBackToAll },
|
||||||
actions: (
|
actions: (
|
||||||
<Pressable
|
<ProviderSettingsAction
|
||||||
onPress={openProviderSettings}
|
serverId={serverId}
|
||||||
disabled={!serverId}
|
provider={view.providerId}
|
||||||
hitSlop={8}
|
|
||||||
style={iconButtonStyle}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={t("modelSelector.openProviderSettings", {
|
accessibilityLabel={t("modelSelector.openProviderSettings", {
|
||||||
provider: view.providerLabel,
|
provider: view.providerLabel,
|
||||||
})}
|
})}
|
||||||
testID={`selector-header-settings-${view.providerId}`}
|
/>
|
||||||
>
|
|
||||||
<HeaderSettingsIcon disabled={!serverId} />
|
|
||||||
</Pressable>
|
|
||||||
),
|
),
|
||||||
search: {
|
search: {
|
||||||
onChange: handleSearchQueryChange,
|
onChange: handleSearchQueryChange,
|
||||||
@@ -279,7 +299,6 @@ export function useModelBrowser({
|
|||||||
}, [
|
}, [
|
||||||
handleBackToAll,
|
handleBackToAll,
|
||||||
handleSearchQueryChange,
|
handleSearchQueryChange,
|
||||||
openProviderSettings,
|
|
||||||
searchResetKey,
|
searchResetKey,
|
||||||
serverId,
|
serverId,
|
||||||
singleProviderView,
|
singleProviderView,
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
||||||
|
import { OverlayLayerProvider } from "@/lib/overlay-root";
|
||||||
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
||||||
|
|
||||||
export function ProviderSettingsHost() {
|
export function ProviderSettingsHost() {
|
||||||
const serverId = useProviderSettingsStore((state) => state.serverId);
|
const serverId = useProviderSettingsStore((state) => state.serverId);
|
||||||
const provider = useProviderSettingsStore((state) => state.provider);
|
const provider = useProviderSettingsStore((state) => state.provider);
|
||||||
const visible = useProviderSettingsStore((state) => state.visible);
|
const visible = useProviderSettingsStore((state) => state.visible);
|
||||||
|
const overlayParentLayer = useProviderSettingsStore((state) => state.overlayParentLayer);
|
||||||
const close = useProviderSettingsStore((state) => state.close);
|
const close = useProviderSettingsStore((state) => state.close);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
@@ -17,12 +19,14 @@ export function ProviderSettingsHost() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProviderDiagnosticSheet
|
<OverlayLayerProvider layer={overlayParentLayer}>
|
||||||
key={`${serverId}:${provider}`}
|
<ProviderDiagnosticSheet
|
||||||
provider={provider}
|
key={`${serverId}:${provider}`}
|
||||||
serverId={serverId}
|
provider={provider}
|
||||||
visible={visible}
|
serverId={serverId}
|
||||||
onClose={handleClose}
|
visible={visible}
|
||||||
/>
|
onClose={handleClose}
|
||||||
|
/>
|
||||||
|
</OverlayLayerProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
type StyleProp,
|
type StyleProp,
|
||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -62,6 +63,12 @@ import {
|
|||||||
} from "@/components/adaptive-modal-sheet";
|
} from "@/components/adaptive-modal-sheet";
|
||||||
import { FloatingSurface } from "@/components/ui/floating";
|
import { FloatingSurface } from "@/components/ui/floating";
|
||||||
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
|
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
|
||||||
|
import {
|
||||||
|
getOverlayRoot,
|
||||||
|
OverlayLayerProvider,
|
||||||
|
useOverlayLayer,
|
||||||
|
useWebOverlayRegistration,
|
||||||
|
} from "@/lib/overlay-root";
|
||||||
import { buildDesktopFrameStyle } from "./combobox-frame-style";
|
import { buildDesktopFrameStyle } from "./combobox-frame-style";
|
||||||
|
|
||||||
export { 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";
|
return key === "ArrowDown" || key === "ArrowUp" || key === "Enter" || key === "Escape";
|
||||||
}
|
}
|
||||||
|
|
||||||
function useWebKeyboardListener(
|
function dispatchDesktopKey(
|
||||||
isOpen: boolean,
|
input: DesktopKeyHandlerInput,
|
||||||
handleDesktopKey: (key: DesktopKey, event?: KeyboardEvent) => void,
|
key: DesktopKey,
|
||||||
) {
|
event?: KeyboardEvent,
|
||||||
useEffect(() => {
|
): boolean {
|
||||||
if (!IS_WEB || !isOpen) return;
|
if (!input.isOpen) return false;
|
||||||
|
if (!IS_WEB && input.isMobile) return false;
|
||||||
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;
|
|
||||||
|
|
||||||
if (key === "ArrowDown" || key === "ArrowUp") {
|
if (key === "ArrowDown" || key === "ArrowUp") {
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
handleDesktopArrowKey(input, key);
|
handleDesktopArrowKey(input, key);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
if (key === "Enter") {
|
if (key === "Enter") {
|
||||||
if (input.orderedVisibleOptions.length === 0) return;
|
if (input.orderedVisibleOptions.length === 0) return false;
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
handleDesktopEnterKey(input);
|
handleDesktopEnterKey(input);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
if (key === "Escape") {
|
if (key === "Escape") {
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
input.handleClose();
|
input.handleClose();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveInitialActiveIndex(
|
function resolveInitialActiveIndex(
|
||||||
@@ -1050,8 +1043,10 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DesktopBodyProps {
|
interface DesktopBodyProps {
|
||||||
|
overlayLayer: number;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
handleClose: () => void;
|
handleClose: () => void;
|
||||||
|
handleDesktopKey: (key: DesktopKey, event?: KeyboardEvent) => boolean;
|
||||||
refs: ReturnType<typeof useFloating>["refs"];
|
refs: ReturnType<typeof useFloating>["refs"];
|
||||||
shouldUseDesktopFade: boolean;
|
shouldUseDesktopFade: boolean;
|
||||||
desktopFrameStyle: StyleProp<ViewStyle>;
|
desktopFrameStyle: StyleProp<ViewStyle>;
|
||||||
@@ -1166,14 +1161,38 @@ function DesktopComboboxOptionsBody(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
|
function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
|
||||||
return (
|
const handleDesktopKey = props.handleDesktopKey;
|
||||||
<Modal
|
const handleWebOverlayKeyDown = useCallback(
|
||||||
transparent
|
(event: KeyboardEvent) => {
|
||||||
animationType="none"
|
if (!isDesktopKey(event.key)) return false;
|
||||||
visible={props.isOpen}
|
return handleDesktopKey(event.key, event);
|
||||||
onRequestClose={props.handleClose}
|
},
|
||||||
>
|
[handleDesktopKey],
|
||||||
<View ref={props.refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}>
|
);
|
||||||
|
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} />
|
<Pressable style={styles.desktopBackdrop} onPress={props.handleClose} />
|
||||||
<FloatingSurface
|
<FloatingSurface
|
||||||
testID="combobox-desktop-container"
|
testID="combobox-desktop-container"
|
||||||
@@ -1181,8 +1200,11 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
|
|||||||
exiting={props.shouldUseDesktopFade ? FadeOut.duration(100) : undefined}
|
exiting={props.shouldUseDesktopFade ? FadeOut.duration(100) : undefined}
|
||||||
style={styles.desktopContainer}
|
style={styles.desktopContainer}
|
||||||
frameStyle={props.desktopFrameStyle}
|
frameStyle={props.desktopFrameStyle}
|
||||||
ref={props.refs.setFloating}
|
ref={setFloatingRef}
|
||||||
collapsable={false}
|
collapsable={false}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal
|
||||||
|
tabIndex={-1}
|
||||||
onLayout={props.handleDesktopContentLayout}
|
onLayout={props.handleDesktopContentLayout}
|
||||||
>
|
>
|
||||||
{props.hasChildren ? (
|
{props.hasChildren ? (
|
||||||
@@ -1215,6 +1237,21 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
|
|||||||
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null}
|
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null}
|
||||||
</FloatingSurface>
|
</FloatingSurface>
|
||||||
</View>
|
</View>
|
||||||
|
</OverlayLayerProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isWeb && typeof document !== "undefined") {
|
||||||
|
return createPortal(overlay, getOverlayRoot());
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
transparent
|
||||||
|
animationType="none"
|
||||||
|
visible={props.isOpen}
|
||||||
|
onRequestClose={props.handleClose}
|
||||||
|
>
|
||||||
|
{overlay}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1257,6 +1294,7 @@ export function Combobox({
|
|||||||
const resolvedEmptyText = emptyText ?? t("common.empty.noOptionsMatchSearch");
|
const resolvedEmptyText = emptyText ?? t("common.empty.noOptionsMatchSearch");
|
||||||
const resolvedTitle = title ?? t("common.actions.select");
|
const resolvedTitle = title ?? t("common.actions.select");
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
|
const floatingLayer = useOverlayLayer("floating");
|
||||||
const safeAreaInsets = useSafeAreaInsets();
|
const safeAreaInsets = useSafeAreaInsets();
|
||||||
const titleColor = theme.colors.foreground;
|
const titleColor = theme.colors.foreground;
|
||||||
const effectiveOptionsPosition = resolveEffectiveOptionsPosition(isMobile, optionsPosition);
|
const effectiveOptionsPosition = resolveEffectiveOptionsPosition(isMobile, optionsPosition);
|
||||||
@@ -1462,7 +1500,7 @@ export function Combobox({
|
|||||||
|
|
||||||
const handleDesktopKey = useCallback(
|
const handleDesktopKey = useCallback(
|
||||||
(key: DesktopKey, event?: KeyboardEvent) => {
|
(key: DesktopKey, event?: KeyboardEvent) => {
|
||||||
dispatchDesktopKey(
|
return dispatchDesktopKey(
|
||||||
{
|
{
|
||||||
isOpen,
|
isOpen,
|
||||||
isMobile,
|
isMobile,
|
||||||
@@ -1479,7 +1517,6 @@ export function Combobox({
|
|||||||
[activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions],
|
[activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions],
|
||||||
);
|
);
|
||||||
|
|
||||||
useWebKeyboardListener(isOpen, handleDesktopKey);
|
|
||||||
useDismissKeyboardOnOpen(isOpen, isMobile);
|
useDismissKeyboardOnOpen(isOpen, isMobile);
|
||||||
|
|
||||||
const handleIndicatorStyle = useMemo(
|
const handleIndicatorStyle = useMemo(
|
||||||
@@ -1556,8 +1593,10 @@ export function Combobox({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DesktopComboboxBody
|
<DesktopComboboxBody
|
||||||
|
overlayLayer={floatingLayer}
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
handleClose={handleClose}
|
handleClose={handleClose}
|
||||||
|
handleDesktopKey={handleDesktopKey}
|
||||||
refs={refs}
|
refs={refs}
|
||||||
shouldUseDesktopFade={shouldUseDesktopFade}
|
shouldUseDesktopFade={shouldUseDesktopFade}
|
||||||
desktopFrameStyle={desktopFrameStyle}
|
desktopFrameStyle={desktopFrameStyle}
|
||||||
@@ -1707,6 +1746,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
desktopOverlay: {
|
desktopOverlay: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
desktopOverlayWeb: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
pointerEvents: "auto" as const,
|
||||||
|
},
|
||||||
desktopBackdrop: {
|
desktopBackdrop: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 0,
|
top: 0,
|
||||||
|
|||||||
@@ -34,7 +34,12 @@ import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
|
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
|
// Action status for menu items with loading/success feedback
|
||||||
export type ActionStatus = "idle" | "pending" | "success";
|
export type ActionStatus = "idle" | "pending" | "success";
|
||||||
@@ -185,6 +190,7 @@ function renderDropdownSurface(input: {
|
|||||||
content: ReactElement;
|
content: ReactElement;
|
||||||
surfaceNativeID: string;
|
surfaceNativeID: string;
|
||||||
onExited: () => void;
|
onExited: () => void;
|
||||||
|
scopeRef: (node: View | null) => void;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const {
|
const {
|
||||||
frameStyle,
|
frameStyle,
|
||||||
@@ -195,6 +201,7 @@ function renderDropdownSurface(input: {
|
|||||||
content,
|
content,
|
||||||
surfaceNativeID,
|
surfaceNativeID,
|
||||||
onExited,
|
onExited,
|
||||||
|
scopeRef,
|
||||||
} = input;
|
} = input;
|
||||||
|
|
||||||
const body = scrollable ? (
|
const body = scrollable ? (
|
||||||
@@ -212,7 +219,9 @@ function renderDropdownSurface(input: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FloatingSurface
|
<FloatingSurface
|
||||||
|
ref={scopeRef}
|
||||||
collapsable={false}
|
collapsable={false}
|
||||||
|
tabIndex={-1}
|
||||||
nativeID={surfaceNativeID}
|
nativeID={surfaceNativeID}
|
||||||
testID={testID}
|
testID={testID}
|
||||||
style={surfaceStyle}
|
style={surfaceStyle}
|
||||||
@@ -449,6 +458,7 @@ export function DropdownMenuContent({
|
|||||||
testID?: string;
|
testID?: string;
|
||||||
}>): ReactElement | null {
|
}>): ReactElement | null {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const floatingLayer = useOverlayLayer("floating");
|
||||||
const { open, setOpen, triggerRef, flushPendingSelect } =
|
const { open, setOpen, triggerRef, flushPendingSelect } =
|
||||||
useDropdownMenuContext("DropdownMenuContent");
|
useDropdownMenuContext("DropdownMenuContent");
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
@@ -502,16 +512,21 @@ export function DropdownMenuContent({
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
}, [setOpen]);
|
}, [setOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleWebOverlayKeyDown = useCallback(
|
||||||
if (!isWeb || !modalVisible || typeof window === "undefined") return undefined;
|
(event: KeyboardEvent) => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
if (event.key !== "Escape") return false;
|
||||||
if (event.key !== "Escape") return;
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
handleClose();
|
handleClose();
|
||||||
};
|
return true;
|
||||||
window.addEventListener("keydown", handleKeyDown, true);
|
},
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
[handleClose],
|
||||||
}, [handleClose, modalVisible]);
|
);
|
||||||
|
const setWebOverlayScope = useWebOverlayRegistration({
|
||||||
|
active: isWeb && modalVisible,
|
||||||
|
layer: floatingLayer,
|
||||||
|
onKeyDown: handleWebOverlayKeyDown,
|
||||||
|
});
|
||||||
|
|
||||||
// Measure trigger when opening
|
// Measure trigger when opening
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -631,27 +646,36 @@ export function DropdownMenuContent({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const overlay = (
|
const overlay = (
|
||||||
<View style={[styles.overlay, isWeb ? styles.overlayWeb : null]}>
|
<OverlayLayerProvider layer={floatingLayer}>
|
||||||
<Pressable
|
<View
|
||||||
accessibilityRole="button"
|
style={[
|
||||||
accessibilityLabel={t("menu.backdrop")}
|
styles.overlay,
|
||||||
style={styles.backdrop}
|
isWeb ? styles.overlayWeb : null,
|
||||||
onPress={handleClose}
|
isWeb ? { zIndex: floatingLayer } : null,
|
||||||
testID={testID ? `${testID}-backdrop` : undefined}
|
]}
|
||||||
/>
|
>
|
||||||
{!closing
|
<Pressable
|
||||||
? renderDropdownSurface({
|
accessibilityRole="button"
|
||||||
frameStyle,
|
accessibilityLabel={t("menu.backdrop")}
|
||||||
testID,
|
style={styles.backdrop}
|
||||||
surfaceStyle,
|
onPress={handleClose}
|
||||||
scrollable,
|
testID={testID ? `${testID}-backdrop` : undefined}
|
||||||
scrollViewportStyle,
|
/>
|
||||||
content,
|
{!closing
|
||||||
surfaceNativeID,
|
? renderDropdownSurface({
|
||||||
onExited: () => setModalVisible(false),
|
frameStyle,
|
||||||
})
|
testID,
|
||||||
: null}
|
surfaceStyle,
|
||||||
</View>
|
scrollable,
|
||||||
|
scrollViewportStyle,
|
||||||
|
content,
|
||||||
|
surfaceNativeID,
|
||||||
|
scopeRef: setWebOverlayScope,
|
||||||
|
onExited: () => setModalVisible(false),
|
||||||
|
})
|
||||||
|
: null}
|
||||||
|
</View>
|
||||||
|
</OverlayLayerProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isWeb && typeof document !== "undefined") {
|
if (isWeb && typeof document !== "undefined") {
|
||||||
@@ -905,7 +929,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
overlayWeb: {
|
overlayWeb: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...StyleSheet.absoluteFillObject,
|
||||||
zIndex: OVERLAY_Z.modal,
|
|
||||||
pointerEvents: "auto" as const,
|
pointerEvents: "auto" as const,
|
||||||
},
|
},
|
||||||
backdrop: {
|
backdrop: {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Modal,
|
Modal,
|
||||||
Pressable,
|
Pressable,
|
||||||
@@ -13,8 +13,13 @@ import { Server } from "lucide-react-native";
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
|
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 { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||||
|
import {
|
||||||
|
OverlayLayerProvider,
|
||||||
|
useGlobalWebOverlayLayer,
|
||||||
|
useWebOverlayRegistration,
|
||||||
|
} from "@/lib/overlay-root";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection";
|
import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection";
|
||||||
import { buildSettingsAddHostRoute } from "@/utils/host-routes";
|
import { buildSettingsAddHostRoute } from "@/utils/host-routes";
|
||||||
@@ -143,6 +148,7 @@ export function HostChooserModal() {
|
|||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && request != null);
|
||||||
|
|
||||||
const requestHosts = useMemo(() => {
|
const requestHosts = useMemo(() => {
|
||||||
if (!request) return [];
|
if (!request) return [];
|
||||||
@@ -182,58 +188,60 @@ export function HostChooserModal() {
|
|||||||
[close, request],
|
[close, request],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleWebOverlayKeyDown = useCallback(
|
||||||
if (!request || isNative || typeof window === "undefined") return;
|
(event: KeyboardEvent) => {
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (
|
if (
|
||||||
event.key !== "ArrowDown" &&
|
event.key !== "ArrowDown" &&
|
||||||
event.key !== "ArrowUp" &&
|
event.key !== "ArrowUp" &&
|
||||||
event.key !== "Enter" &&
|
event.key !== "Enter" &&
|
||||||
event.key !== "Escape"
|
event.key !== "Escape"
|
||||||
) {
|
) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
close();
|
close();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
const host = options[activeOptionIndex];
|
const host = options[activeOptionIndex];
|
||||||
if (!host) return;
|
if (!host) return false;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
chooseHost(host.serverId);
|
chooseHost(host.serverId);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.length === 0) return;
|
if (options.length === 0) return false;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const next = event.key === "ArrowDown" ? activeOptionIndex + 1 : activeOptionIndex - 1;
|
const next = event.key === "ArrowDown" ? activeOptionIndex + 1 : activeOptionIndex - 1;
|
||||||
if (next < 0) {
|
if (next < 0) {
|
||||||
setActiveIndex(options.length - 1);
|
setActiveIndex(options.length - 1);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
if (next >= options.length) {
|
if (next >= options.length) {
|
||||||
setActiveIndex(0);
|
setActiveIndex(0);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
setActiveIndex(next);
|
setActiveIndex(next);
|
||||||
};
|
return true;
|
||||||
|
},
|
||||||
window.addEventListener("keydown", handleKeyDown, true);
|
[activeOptionIndex, chooseHost, close, options],
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
);
|
||||||
}, [activeOptionIndex, chooseHost, close, options, request]);
|
const setWebOverlayScope = useWebOverlayRegistration({
|
||||||
|
active: isWeb && request != null,
|
||||||
|
layer: modalLayer,
|
||||||
|
onKeyDown: handleWebOverlayKeyDown,
|
||||||
|
});
|
||||||
|
|
||||||
if (!request) return null;
|
if (!request) return null;
|
||||||
|
|
||||||
return (
|
const modal = (
|
||||||
<Modal visible transparent animationType="fade" onRequestClose={close} testID="host-chooser">
|
<Modal visible transparent animationType="fade" onRequestClose={close} testID="host-chooser">
|
||||||
<View style={styles.overlay}>
|
<View style={styles.overlay}>
|
||||||
<Pressable style={styles.backdrop} onPress={close} />
|
<Pressable style={styles.backdrop} onPress={close} />
|
||||||
<View style={styles.panel}>
|
<View ref={setWebOverlayScope} style={styles.panel}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.title}>{request.title}</Text>
|
<Text style={styles.title}>{request.title}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -268,6 +276,8 @@ export function HostChooserModal() {
|
|||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return createElement(OverlayLayerProvider, { layer: isWeb ? modalLayer : 0 }, modal);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
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.)
|
* Shared overlay root for web portals (modals, toasts, etc.)
|
||||||
* This ensures consistent stacking order by controlling a single overlay container.
|
* This ensures consistent stacking order by controlling a single overlay container.
|
||||||
*
|
*
|
||||||
* Z-index scale within overlay root:
|
* Z-index scale within overlay root:
|
||||||
* - Modal backdrop/content: 10
|
* - Floating panel: parent layer + 10
|
||||||
* - Toast: 20
|
* - Modal: parent layer + 20
|
||||||
* - Tooltip: 30
|
* - 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 {
|
export function getOverlayRoot(): HTMLElement {
|
||||||
let el = document.getElementById("overlay-root");
|
let el = document.getElementById("overlay-root");
|
||||||
@@ -21,7 +37,240 @@ export function getOverlayRoot(): HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const OVERLAY_Z = {
|
export const OVERLAY_Z = {
|
||||||
modal: 10,
|
floating: 10,
|
||||||
toast: 20,
|
modal: 20,
|
||||||
tooltip: 30,
|
toast: 10_000,
|
||||||
|
tooltip: 20_000,
|
||||||
} as const;
|
} 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 {
|
interface ProviderSettingsTarget {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
|
overlayParentLayer?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProviderSettingsStoreState {
|
interface ProviderSettingsStoreState {
|
||||||
serverId: string | null;
|
serverId: string | null;
|
||||||
provider: string | null;
|
provider: string | null;
|
||||||
|
overlayParentLayer: number;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
open: (target: ProviderSettingsTarget) => void;
|
open: (target: ProviderSettingsTarget) => void;
|
||||||
close: () => void;
|
close: () => void;
|
||||||
@@ -16,9 +18,10 @@ interface ProviderSettingsStoreState {
|
|||||||
export const useProviderSettingsStore = create<ProviderSettingsStoreState>()((set) => ({
|
export const useProviderSettingsStore = create<ProviderSettingsStoreState>()((set) => ({
|
||||||
serverId: null,
|
serverId: null,
|
||||||
provider: null,
|
provider: null,
|
||||||
|
overlayParentLayer: 0,
|
||||||
visible: false,
|
visible: false,
|
||||||
open: ({ serverId, provider }) => {
|
open: ({ serverId, provider, overlayParentLayer = 0 }) => {
|
||||||
set({ serverId, provider, visible: true });
|
set({ serverId, provider, overlayParentLayer, visible: true });
|
||||||
},
|
},
|
||||||
close: () => {
|
close: () => {
|
||||||
set({ visible: false });
|
set({ visible: false });
|
||||||
|
|||||||
Reference in New Issue
Block a user