Fix autocomplete popover anchoring
Position portal content relative to its render host so the composer anchor stays correct when surrounding layout shifts. This preserves the Portal surface needed for Android scrolling and touch behavior.
This commit is contained in:
+40
-43
@@ -7,18 +7,18 @@ canonical files and are trying to add or change one.
|
|||||||
|
|
||||||
## Canonical files
|
## Canonical files
|
||||||
|
|
||||||
| File | Use case |
|
| File | Use case |
|
||||||
| ---------------------------------------- | -------------------------------------------------------------- |
|
| ---------------------------------------- | ----------------------------------------------------------------- |
|
||||||
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
|
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
|
||||||
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
|
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
|
||||||
| `components/workspace-hover-card.tsx` | Desktop-web hover card with measure + computePosition + Portal |
|
| `components/workspace-hover-card.tsx` | Desktop-web hover card with measure + computePosition + Portal |
|
||||||
| `components/ui/autocomplete-popover.tsx` | Inline slash-command autocomplete above a focused TextInput |
|
| `components/ui/autocomplete-popover.tsx` | Slash-command autocomplete anchored to the focused composer input |
|
||||||
|
|
||||||
Each handles a different mix of concerns: combobox owns input focus, tooltip is
|
Each handles a different mix of concerns: combobox owns input focus, tooltip is
|
||||||
non-interactive, hover-card is web-only desktop, autocomplete coexists with a
|
non-interactive, hover-card is web-only desktop, autocomplete keeps the composer
|
||||||
focused TextInput. There is no shared "floating panel" primitive yet — when a
|
input focused while its scrollable list lives in a Portal. There is no shared
|
||||||
fifth use case shows up we can revisit; until then prefer copying the closest
|
"floating panel" primitive yet — when a fifth use case shows up we can revisit;
|
||||||
file and trimming. The four gotchas below are what each of them re-learned.
|
until then prefer copying the closest file and trimming.
|
||||||
|
|
||||||
## Gotcha 1 — Android touch hit-test by parent bounds
|
## Gotcha 1 — Android touch hit-test by parent bounds
|
||||||
|
|
||||||
@@ -43,12 +43,11 @@ Two escape hatches in the codebase:
|
|||||||
hoists the React subtree to a fixed mount point whose bounds cover the
|
hoists the React subtree to a fixed mount point whose bounds cover the
|
||||||
screen. Same window, same IME, hit-test works because the new parent is
|
screen. Same window, same IME, hit-test works because the new parent is
|
||||||
full-screen. This is the right default when you must keep IME attachment.
|
full-screen. This is the right default when you must keep IME attachment.
|
||||||
Choose the host by layer: app-global overlays use the root host; composer
|
Choose the host by layer: app-global overlays use the root host; content
|
||||||
autocomplete uses the content floating-panel host so sliding sidebars cover
|
overlays can use the current `FloatingPanelPortalHost` so sliding sidebars
|
||||||
it.
|
cover them.
|
||||||
|
|
||||||
Choose Modal vs Portal by whether you need the underlying input to keep its
|
Choose Modal vs Portal by whether the underlying input can lose its keyboard.
|
||||||
keyboard.
|
|
||||||
|
|
||||||
## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
|
## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
|
||||||
|
|
||||||
@@ -60,8 +59,7 @@ quietly relying on:
|
|||||||
component may stay mounted (offscreen, in a tab) — the popover stays with it.
|
component may stay mounted (offscreen, in a tab) — the popover stays with it.
|
||||||
Gate `visible` on a screen-focus signal. For panes inside `agent-panel`, the
|
Gate `visible` on a screen-focus signal. For panes inside `agent-panel`, the
|
||||||
`isPaneFocused` prop already exists and flips on pane switches; pass
|
`isPaneFocused` prop already exists and flips on pane switches; pass
|
||||||
`visible={isYourOwnVisible && isPaneFocused}`. See
|
`visible={isYourOwnVisible && isPaneFocused}`.
|
||||||
`composer.tsx:1604` and `autocomplete-popover.tsx`.
|
|
||||||
- **Transforms.** The composer is wrapped in a Reanimated `Animated.View` with
|
- **Transforms.** The composer is wrapped in a Reanimated `Animated.View` with
|
||||||
`translateY: -keyboardShift` (see `use-keyboard-shift-style.ts`). The chat
|
`translateY: -keyboardShift` (see `use-keyboard-shift-style.ts`). The chat
|
||||||
content has the same transform applied (`agent-panel.tsx:939`). They move
|
content has the same transform applied (`agent-panel.tsx:939`). They move
|
||||||
@@ -69,11 +67,12 @@ quietly relying on:
|
|||||||
the composer tree — it does not get that transform unless you apply it
|
the composer tree — it does not get that transform unless you apply it
|
||||||
yourself.
|
yourself.
|
||||||
- **Layering.** The default root host renders after app content, so it sits
|
- **Layering.** The default root host renders after app content, so it sits
|
||||||
above compact sidebars. Autocomplete should use
|
above compact sidebars. Content overlays that must sit below sidebars should
|
||||||
`useFloatingPanelPortalHostName()` and render through the current
|
use the current `FloatingPanelPortalHost`.
|
||||||
`FloatingPanelPortalHost` instead. The app shell provides a default content
|
- **Coordinate systems.** `measureInWindow` gives window coordinates. A Portal
|
||||||
host; workspace screens provide a per-workspace host between the center pane
|
renders inside its host, not necessarily at window origin. Position anchored
|
||||||
and explorer sidebar so both compact sidebars cover autocomplete.
|
content relative to the host: `anchorRect - hostRect`. This is what
|
||||||
|
`measureFloatingPanelPortalHost()` is for.
|
||||||
|
|
||||||
The fix for transforms is Gotcha 3.
|
The fix for transforms is Gotcha 3.
|
||||||
|
|
||||||
@@ -85,7 +84,8 @@ properties, and Android's `getLocationInWindow` reads transformed coords). In
|
|||||||
practice it's racy — the measurement may snapshot mid-animation, and on Android
|
practice it's racy — the measurement may snapshot mid-animation, and on Android
|
||||||
with Reanimated worklets the result is not always stable.
|
with Reanimated worklets the result is not always stable.
|
||||||
|
|
||||||
Do not try to track the keyboard by re-measuring on every frame. Instead,
|
If the panel cannot stay inside the transformed ancestor, do not try to track
|
||||||
|
the keyboard by re-measuring on every frame. Instead,
|
||||||
**slave the popover's transform to the same SharedValue the composer uses**:
|
**slave the popover's transform to the same SharedValue the composer uses**:
|
||||||
|
|
||||||
1. Snapshot `openShift = shift.value` at the moment you measure the anchor.
|
1. Snapshot `openShift = shift.value` at the moment you measure the anchor.
|
||||||
@@ -95,31 +95,29 @@ Do not try to track the keyboard by re-measuring on every frame. Instead,
|
|||||||
When `shift` equals `openShift`, the translate is 0 and the popover sits at
|
When `shift` equals `openShift`, the translate is 0 and the popover sits at
|
||||||
the measured position. When the keyboard moves afterward, the delta translates
|
the measured position. When the keyboard moves afterward, the delta translates
|
||||||
the popover by exactly the amount the composer translates. They move in
|
the popover by exactly the amount the composer translates. They move in
|
||||||
lockstep, no re-measurement needed. See `autocomplete-popover.tsx` —
|
lockstep, no re-measurement needed.
|
||||||
`openShift` / `shift` / `animatedTransformStyle`.
|
|
||||||
|
|
||||||
Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only
|
Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only
|
||||||
to refresh the snapshot if the keyboard was mid-transition when the popover
|
to refresh the snapshot if the keyboard was mid-transition when the popover
|
||||||
opened.
|
opened.
|
||||||
|
|
||||||
## Gotcha 4 — Status-bar offset for Portal-based positioning on Android
|
## Gotcha 4 — Host-relative positioning before platform offsets
|
||||||
|
|
||||||
On Android, `measureInWindow` returns coords **below** the status bar (the
|
The generic anchored-overlay rule is:
|
||||||
status bar is system-owned space). A Portal overlay positioned at `top: 0`
|
|
||||||
inside the `PortalProvider` may start at the **top of the window** (above the
|
|
||||||
status bar) depending on the wrapper chain. The result: position the popover
|
|
||||||
at the measured `rect.y` and it sits `StatusBar.currentHeight` higher than the
|
|
||||||
anchor on Android.
|
|
||||||
|
|
||||||
Mirror what tooltip does: shift the measured rect down by the status bar height
|
1. Measure the anchor with `measureInWindow`.
|
||||||
on Android only.
|
2. Measure the Portal host with `measureFloatingPanelPortalHost(hostName)`.
|
||||||
|
3. Position with anchor coordinates relative to the host:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const statusBarOffset = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
left = anchorRect.x - hostRect.x;
|
||||||
setAnchorRect({ ...rect, y: rect.y + statusBarOffset });
|
bottom = hostRect.height - (anchorRect.y - hostRect.y) + offset;
|
||||||
```
|
```
|
||||||
|
|
||||||
See `tooltip.tsx` and `autocomplete-popover.tsx`. iOS and web do not need this.
|
Do this before adding any platform offset. If anchor and host are both measured
|
||||||
|
with `measureInWindow`, Android's status-bar coordinate behavior cancels out.
|
||||||
|
Only add a status-bar offset when the render surface is not measured in the same
|
||||||
|
coordinate system. See `tooltip.tsx` for that separate case.
|
||||||
|
|
||||||
## Gotcha 5 — The two-measurement flash
|
## Gotcha 5 — The two-measurement flash
|
||||||
|
|
||||||
@@ -165,18 +163,17 @@ Before you write a new one, ask:
|
|||||||
If no, use Portal.
|
If no, use Portal.
|
||||||
2. **Does the panel need to dismiss on screen change?** Almost always yes —
|
2. **Does the panel need to dismiss on screen change?** Almost always yes —
|
||||||
gate `visible` on an upstream focus prop (`isPaneFocused` or similar).
|
gate `visible` on an upstream focus prop (`isPaneFocused` or similar).
|
||||||
3. **Does the panel sit above something that moves with the keyboard?** If
|
3. **Is the panel rendered in a Portal host?** Measure the host too. Never use
|
||||||
|
raw window coordinates as local Portal coordinates.
|
||||||
|
4. **Does the panel sit above something that moves with the keyboard?** If
|
||||||
yes, slave a Reanimated transform to the same SharedValue (Gotcha 3).
|
yes, slave a Reanimated transform to the same SharedValue (Gotcha 3).
|
||||||
If no, you can probably skip the transform entirely.
|
If no, you can probably skip the transform entirely.
|
||||||
4. **Will the panel's content height vary?** If yes, you need both
|
5. **Will the panel's content height vary?** If yes, you need both
|
||||||
`anchorRect` and `contentSize` for positioning → apply Gotcha 5 (return
|
`anchorRect` and `contentSize` for positioning → apply Gotcha 5 (return
|
||||||
null until anchor, then opacity-0 until contentSize). If no — content has
|
null until anchor, then opacity-0 until contentSize). If no — content has
|
||||||
a known fixed max height — you might be able to use bottom-anchored
|
a known fixed max height — you might be able to use bottom-anchored
|
||||||
positioning (`bottom: windowHeight - anchor.y + gap`) and skip the
|
positioning (`bottom: windowHeight - anchor.y + gap`) and skip the
|
||||||
`contentSize` round-trip entirely. **But only if the height is genuinely
|
`contentSize` round-trip entirely. **But only if the height is genuinely
|
||||||
bounded** — autocomplete's inner detail card escapes the 220-cap
|
bounded**. Verify before you commit.
|
||||||
container, which is why bottom-anchored looked clean but rendered
|
|
||||||
wrong. Verify before you commit.
|
|
||||||
5. **Are you on Android with Portal?** Add the status-bar offset (Gotcha 4).
|
|
||||||
|
|
||||||
Then copy the closest canonical file and trim.
|
Then copy the closest canonical file and trim.
|
||||||
|
|||||||
@@ -471,6 +471,33 @@ test.describe("Composer autocomplete", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("stays anchored to the composer when the desktop sidebar is open", async ({ page }) => {
|
||||||
|
await installListCommandsStub(page);
|
||||||
|
const agent = await openReadyMockAgent(page);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(page.getByTestId("sidebar-sessions")).toBeVisible({ timeout: 30_000 });
|
||||||
|
const input = composerLocator(page);
|
||||||
|
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await input.fill("/");
|
||||||
|
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||||
|
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const composerBox = await page.getByTestId("message-input-root").boundingBox();
|
||||||
|
const popoverBox = await popover.boundingBox();
|
||||||
|
expect(composerBox).not.toBeNull();
|
||||||
|
expect(popoverBox).not.toBeNull();
|
||||||
|
|
||||||
|
expect(Math.abs(popoverBox!.x - composerBox!.x)).toBeLessThanOrEqual(4);
|
||||||
|
expect(Math.abs(popoverBox!.width - composerBox!.width)).toBeLessThanOrEqual(4);
|
||||||
|
} finally {
|
||||||
|
await agent.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test.describe("compact sidebar layering", () => {
|
test.describe("compact sidebar layering", () => {
|
||||||
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
||||||
|
|
||||||
|
|||||||
@@ -1594,6 +1594,7 @@ export function Composer({
|
|||||||
const githubEmptyText = githubSearchResultsQuery.isFetching
|
const githubEmptyText = githubSearchResultsQuery.isFetching
|
||||||
? "Searching..."
|
? "Searching..."
|
||||||
: "No results found.";
|
: "No results found.";
|
||||||
|
const autocompleteVisible = autocomplete.isVisible && isPaneFocused;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View style={composerContainerStyle}>
|
<Animated.View style={composerContainerStyle}>
|
||||||
@@ -1606,7 +1607,7 @@ export function Composer({
|
|||||||
|
|
||||||
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
|
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
|
||||||
<AutocompletePopover
|
<AutocompletePopover
|
||||||
visible={autocomplete.isVisible && isPaneFocused}
|
visible={autocompleteVisible}
|
||||||
anchorRef={messageInputContainerRef}
|
anchorRef={messageInputContainerRef}
|
||||||
options={autocomplete.options}
|
options={autocomplete.options}
|
||||||
selectedIndex={autocomplete.selectedIndex}
|
selectedIndex={autocomplete.selectedIndex}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactElement } from "react";
|
import { useEffect, useMemo, useState, type ReactElement, type RefObject } from "react";
|
||||||
import { Keyboard, Platform, StatusBar, View, useWindowDimensions } from "react-native";
|
import { Keyboard, View, useWindowDimensions } from "react-native";
|
||||||
import { Portal } from "@gorhom/portal";
|
import { Portal } from "@gorhom/portal";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
@@ -10,7 +10,10 @@ import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete";
|
import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete";
|
||||||
import { useFloatingPanelPortalHostName } from "@/components/ui/floating-panel-portal";
|
import {
|
||||||
|
measureFloatingPanelPortalHost,
|
||||||
|
useFloatingPanelPortalHostName,
|
||||||
|
} from "@/components/ui/floating-panel-portal";
|
||||||
import { SPACING } from "@/styles/theme";
|
import { SPACING } from "@/styles/theme";
|
||||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||||
|
|
||||||
@@ -23,6 +26,13 @@ interface Rect {
|
|||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RelativeAnchorRect {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
hostHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
function measureElement(element: View): Promise<Rect> {
|
function measureElement(element: View): Promise<Rect> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
element.measureInWindow((x, y, width, height) => {
|
element.measureInWindow((x, y, width, height) => {
|
||||||
@@ -33,7 +43,7 @@ function measureElement(element: View): Promise<Rect> {
|
|||||||
|
|
||||||
interface AutocompletePopoverProps {
|
interface AutocompletePopoverProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
anchorRef: React.RefObject<View | null>;
|
anchorRef: RefObject<View | null>;
|
||||||
options: readonly AutocompleteOption[];
|
options: readonly AutocompleteOption[];
|
||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
onSelect: (option: AutocompleteOption) => void;
|
onSelect: (option: AutocompleteOption) => void;
|
||||||
@@ -54,8 +64,8 @@ export function AutocompletePopover({
|
|||||||
loadingText,
|
loadingText,
|
||||||
emptyText,
|
emptyText,
|
||||||
}: AutocompletePopoverProps): ReactElement | null {
|
}: AutocompletePopoverProps): ReactElement | null {
|
||||||
const [anchorRect, setAnchorRect] = useState<Rect | null>(null);
|
const [relativeAnchorRect, setRelativeAnchorRect] = useState<RelativeAnchorRect | null>(null);
|
||||||
const { height: windowHeight } = useWindowDimensions();
|
const windowDimensions = useWindowDimensions();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const portalHostName = useFloatingPanelPortalHostName();
|
const portalHostName = useFloatingPanelPortalHostName();
|
||||||
|
|
||||||
@@ -65,57 +75,69 @@ export function AutocompletePopover({
|
|||||||
bottomInsetSV.value = insets.bottom;
|
bottomInsetSV.value = insets.bottom;
|
||||||
}, [bottomInsetSV, insets.bottom]);
|
}, [bottomInsetSV, insets.bottom]);
|
||||||
|
|
||||||
// Same shift formula as useKeyboardShiftStyle({mode: "translate"}), so the popover
|
|
||||||
// tracks the composer's keyboard translate in lockstep.
|
|
||||||
const shift = useDerivedValue(() =>
|
const shift = useDerivedValue(() =>
|
||||||
Math.max(0, Math.abs(rawKeyboardHeight.value) - bottomInsetSV.value),
|
Math.max(0, Math.abs(rawKeyboardHeight.value) - bottomInsetSV.value),
|
||||||
);
|
);
|
||||||
// Snapshot of `shift` at the moment we measured the anchor. Translate applied to the
|
|
||||||
// popover is `openShift - shift`, so when shift == openShift the popover sits at the
|
|
||||||
// measured position; when keyboard moves the popover translates with the composer.
|
|
||||||
const openShift = useSharedValue(0);
|
const openShift = useSharedValue(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) {
|
if (!visible || (options.length > 0 && selectedIndex < 0)) {
|
||||||
setAnchorRect(null);
|
setRelativeAnchorRect(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
// measureInWindow on Android returns coords below the status bar, while the Portal
|
|
||||||
// overlay starts at the top of the window. Mirror tooltip.tsx and shift the rect
|
|
||||||
// down by the status bar height to keep both in the same coord system.
|
|
||||||
const statusBarOffset = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
|
||||||
const remeasure = () => {
|
const remeasure = () => {
|
||||||
const element = anchorRef.current;
|
const anchorElement = anchorRef.current;
|
||||||
if (!element) return;
|
if (!anchorElement) return;
|
||||||
void measureElement(element).then((rect) => {
|
void Promise.all([
|
||||||
if (cancelled) return undefined;
|
measureElement(anchorElement),
|
||||||
setAnchorRect({ ...rect, y: rect.y + statusBarOffset });
|
measureFloatingPanelPortalHost(portalHostName),
|
||||||
|
]).then(([anchorRect, hostRect]) => {
|
||||||
|
if (cancelled || !hostRect) return undefined;
|
||||||
|
setRelativeAnchorRect({
|
||||||
|
x: anchorRect.x - hostRect.x,
|
||||||
|
y: anchorRect.y - hostRect.y,
|
||||||
|
width: anchorRect.width,
|
||||||
|
hostHeight: hostRect.height,
|
||||||
|
});
|
||||||
openShift.value = shift.value;
|
openShift.value = shift.value;
|
||||||
return undefined;
|
return undefined;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
remeasure();
|
remeasure();
|
||||||
|
const raf = requestAnimationFrame(remeasure);
|
||||||
const subscriptions = (["keyboardDidShow", "keyboardDidHide"] as const).map((event) =>
|
const subscriptions = (["keyboardDidShow", "keyboardDidHide"] as const).map((event) =>
|
||||||
Keyboard.addListener(event, () => requestAnimationFrame(remeasure)),
|
Keyboard.addListener(event, () => requestAnimationFrame(remeasure)),
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
for (const sub of subscriptions) sub.remove();
|
for (const sub of subscriptions) sub.remove();
|
||||||
};
|
};
|
||||||
}, [visible, anchorRef, openShift, shift]);
|
}, [
|
||||||
|
visible,
|
||||||
|
options.length,
|
||||||
|
selectedIndex,
|
||||||
|
anchorRef,
|
||||||
|
portalHostName,
|
||||||
|
openShift,
|
||||||
|
shift,
|
||||||
|
windowDimensions.width,
|
||||||
|
windowDimensions.height,
|
||||||
|
]);
|
||||||
|
|
||||||
const baseStyle = useMemo(() => {
|
const baseStyle = useMemo(() => {
|
||||||
if (!anchorRect) return null;
|
if (!relativeAnchorRect) return null;
|
||||||
return inlineUnistylesStyle({
|
return inlineUnistylesStyle({
|
||||||
position: "absolute" as const,
|
position: "absolute" as const,
|
||||||
bottom: windowHeight - anchorRect.y + OFFSET_FROM_ANCHOR,
|
bottom: relativeAnchorRect.hostHeight - relativeAnchorRect.y + OFFSET_FROM_ANCHOR,
|
||||||
left: anchorRect.x,
|
left: relativeAnchorRect.x,
|
||||||
width: anchorRect.width,
|
width: relativeAnchorRect.width,
|
||||||
});
|
});
|
||||||
}, [anchorRect, windowHeight]);
|
}, [relativeAnchorRect]);
|
||||||
|
|
||||||
const animatedTransformStyle = useAnimatedStyle(() => ({
|
const animatedTransformStyle = useAnimatedStyle(() => ({
|
||||||
transform: [{ translateY: openShift.value - shift.value }],
|
transform: [{ translateY: openShift.value - shift.value }],
|
||||||
@@ -126,7 +148,8 @@ export function AutocompletePopover({
|
|||||||
[baseStyle, animatedTransformStyle],
|
[baseStyle, animatedTransformStyle],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!visible || !anchorRect || !baseStyle) return null;
|
if (!visible || !relativeAnchorRect || !baseStyle) return null;
|
||||||
|
if (options.length > 0 && selectedIndex < 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Portal hostName={portalHostName}>
|
<Portal hostName={portalHostName}>
|
||||||
|
|||||||
@@ -1,10 +1,41 @@
|
|||||||
import { createContext, type ReactElement, type ReactNode, useContext } from "react";
|
import {
|
||||||
|
createContext,
|
||||||
|
type ReactElement,
|
||||||
|
type ReactNode,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
import { StyleSheet, View } from "react-native";
|
||||||
import { PortalHost } from "@gorhom/portal";
|
import { PortalHost } from "@gorhom/portal";
|
||||||
|
|
||||||
export const DEFAULT_FLOATING_PANEL_PORTAL_HOST = "content-floating-panels";
|
export const DEFAULT_FLOATING_PANEL_PORTAL_HOST = "content-floating-panels";
|
||||||
|
|
||||||
const FloatingPanelPortalHostNameContext = createContext(DEFAULT_FLOATING_PANEL_PORTAL_HOST);
|
const FloatingPanelPortalHostNameContext = createContext(DEFAULT_FLOATING_PANEL_PORTAL_HOST);
|
||||||
|
|
||||||
|
interface Rect {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const portalHostElements = new Map<string, View>();
|
||||||
|
|
||||||
|
function measureElement(element: View): Promise<Rect> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
element.measureInWindow((x, y, width, height) => {
|
||||||
|
resolve({ x, y, width, height });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function measureFloatingPanelPortalHost(name: string): Promise<Rect | null> {
|
||||||
|
const element = portalHostElements.get(name);
|
||||||
|
if (!element) return Promise.resolve(null);
|
||||||
|
return measureElement(element);
|
||||||
|
}
|
||||||
|
|
||||||
export function FloatingPanelPortalHostNameProvider({
|
export function FloatingPanelPortalHostNameProvider({
|
||||||
hostName,
|
hostName,
|
||||||
children,
|
children,
|
||||||
@@ -28,5 +59,28 @@ export function FloatingPanelPortalHost({
|
|||||||
}: {
|
}: {
|
||||||
name?: string;
|
name?: string;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
return <PortalHost name={name} />;
|
const hostRef = useRef<View>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const host = hostRef.current;
|
||||||
|
if (!host) return undefined;
|
||||||
|
portalHostElements.set(name, host);
|
||||||
|
return () => {
|
||||||
|
if (portalHostElements.get(name) === host) {
|
||||||
|
portalHostElements.delete(name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View ref={hostRef} collapsable={false} pointerEvents="box-none" style={styles.host}>
|
||||||
|
<PortalHost name={name} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
host: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user