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:
@@ -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