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:
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user