mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-14 12:25:05 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11c28a48ea |
@@ -0,0 +1,152 @@
|
||||
import React, { act, type ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { PortalProvider } from "@gorhom/portal";
|
||||
import { View } from "react-native";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { KeyboardShiftProvider } from "@/keyboard/shift";
|
||||
import type { AutocompleteOption } from "./autocomplete";
|
||||
import { AutocompletePopover } from "./autocomplete-popover";
|
||||
import { FloatingPanelPortalHost } from "./floating-panel-portal";
|
||||
|
||||
// App sources compile against the classic JSX runtime, which expects React on the global.
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
|
||||
/**
|
||||
* The popover's height bound is a Reanimated style. Reanimated computes a style's first value
|
||||
* when its hook first runs and sends later values from an effect, after the commit that mounts
|
||||
* the view. Native paints that commit: a bound computed while the popover was closed painted the
|
||||
* popover at its minimum height, hanging down over the composer, for a frame on every open.
|
||||
* Web runs the effect before paint, so the wrong value only shows as the first style the DOM
|
||||
* receives.
|
||||
*/
|
||||
|
||||
const SAFE_AREA = {
|
||||
frame: { x: 0, y: 0, width: 390, height: 844 },
|
||||
insets: { top: 0, left: 0, right: 0, bottom: 0 },
|
||||
};
|
||||
const COMPOSER_TOP = 600;
|
||||
const OPTIONS: AutocompleteOption[] = ["help", "hello", "history"].map((name) => ({
|
||||
id: name,
|
||||
label: `/${name}`,
|
||||
icon: "command",
|
||||
}));
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
const SCREEN_STYLE = { width: 390, height: 844 };
|
||||
const COMPOSER_STYLE = { position: "absolute" as const, top: COMPOSER_TOP, left: 16, width: 358 };
|
||||
const INPUT_STYLE = { height: 120 };
|
||||
function ignoreSelection(): void {}
|
||||
|
||||
function Providers({ children }: { children: ReactNode }): ReactNode {
|
||||
return (
|
||||
<SafeAreaProvider initialMetrics={SAFE_AREA}>
|
||||
<KeyboardProvider>
|
||||
<KeyboardShiftProvider>
|
||||
<PortalProvider>{children}</PortalProvider>
|
||||
</KeyboardShiftProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Screen({ visible }: { visible: boolean }): ReactNode {
|
||||
return (
|
||||
<Providers>
|
||||
<View style={SCREEN_STYLE}>
|
||||
<View style={COMPOSER_STYLE}>
|
||||
<AutocompletePopover
|
||||
visible={visible}
|
||||
options={OPTIONS}
|
||||
selectedIndex={OPTIONS.length - 1}
|
||||
onSelect={ignoreSelection}
|
||||
>
|
||||
<View style={INPUT_STYLE} />
|
||||
</AutocompletePopover>
|
||||
</View>
|
||||
<FloatingPanelPortalHost />
|
||||
</View>
|
||||
</Providers>
|
||||
);
|
||||
}
|
||||
|
||||
function render(node: ReactNode): void {
|
||||
if (!container) {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
}
|
||||
act(() => root?.render(node));
|
||||
}
|
||||
|
||||
/** Every max-height the popover's frame has held, starting with the one it mounted with. */
|
||||
function recordPopoverMaxHeights(): { values: string[]; stop: () => void } {
|
||||
const values: string[] = [];
|
||||
const observer = new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
if (record.type === "childList") {
|
||||
for (const node of record.addedNodes) {
|
||||
if (!(node instanceof HTMLElement)) continue;
|
||||
const popover = node.matches('[data-testid="composer-autocomplete-popover"]')
|
||||
? node
|
||||
: node.querySelector<HTMLElement>('[data-testid="composer-autocomplete-popover"]');
|
||||
// The attribute records below carry what the style was before each change; this
|
||||
// carries the style at insertion when nothing changed it afterwards.
|
||||
if (popover && values.length === 0) values.push(popover.style.maxHeight);
|
||||
}
|
||||
}
|
||||
if (
|
||||
record.type === "attributes" &&
|
||||
record.target instanceof HTMLElement &&
|
||||
record.target.dataset.testid === "composer-autocomplete-popover"
|
||||
) {
|
||||
const before = /max-height:\s*([^;]+)/.exec(record.oldValue ?? "")?.[1]?.trim() ?? "";
|
||||
if (values.length <= 1) values.splice(0, values.length, before);
|
||||
values.push(record.target.style.maxHeight);
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["style"],
|
||||
attributeOldValue: true,
|
||||
});
|
||||
return { values, stop: () => observer.disconnect() };
|
||||
}
|
||||
|
||||
async function waitForPopover(): Promise<HTMLElement> {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
const popover = document.querySelector<HTMLElement>(
|
||||
'[data-testid="composer-autocomplete-popover"]',
|
||||
);
|
||||
if (popover) return popover;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
throw new Error("The popover did not open");
|
||||
}
|
||||
|
||||
it("opens with its height bound instead of the bound it had while closed", async () => {
|
||||
// The composer mounts first and opens the popover later, as typing `/` does.
|
||||
render(<Screen visible={false} />);
|
||||
const recording = recordPopoverMaxHeights();
|
||||
render(<Screen visible />);
|
||||
const popover = await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
recording.stop();
|
||||
|
||||
const settled = popover.style.maxHeight;
|
||||
expect(Number.parseFloat(settled)).toBeGreaterThan(0);
|
||||
expect(recording.values[0]).toBe(settled);
|
||||
});
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
} from "react";
|
||||
import { View, useWindowDimensions, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import Animated, { useAnimatedReaction, useAnimatedStyle } from "react-native-reanimated";
|
||||
import Animated, {
|
||||
useAnimatedReaction,
|
||||
useAnimatedStyle,
|
||||
type DerivedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import {
|
||||
@@ -126,38 +130,62 @@ export function AutocompletePopover({
|
||||
});
|
||||
}, [relativeAnchorRect]);
|
||||
|
||||
const anchorY = relativeAnchorRect?.y ?? 0;
|
||||
const reservedLift = anchor.reservedLift;
|
||||
const heightStyle = useAnimatedStyle(
|
||||
() => ({
|
||||
maxHeight: Math.max(
|
||||
0,
|
||||
anchorY - reservedLift.value - safeAreaInsets.top - OFFSET_FROM_ANCHOR * 2,
|
||||
),
|
||||
}),
|
||||
[anchorY, reservedLift, safeAreaInsets.top],
|
||||
);
|
||||
|
||||
const showPopover = canMeasure && relativeAnchorRect && baseStyle;
|
||||
|
||||
// The translate view is the popover's own frame, so it lifts only the popover.
|
||||
return (
|
||||
<View ref={anchorRef} onLayout={remeasure} style={anchorStyle} collapsable={false}>
|
||||
{children}
|
||||
{showPopover ? (
|
||||
{canMeasure && relativeAnchorRect && baseStyle ? (
|
||||
<Portal hostName={portalHostName}>
|
||||
<KeyboardTranslateView {...anchor.translation} style={baseStyle}>
|
||||
<Animated.View testID="composer-autocomplete-popover" style={heightStyle}>
|
||||
<PopoverFrame
|
||||
anchorY={relativeAnchorRect.y}
|
||||
reservedLift={anchor.reservedLift}
|
||||
topInset={safeAreaInsets.top}
|
||||
>
|
||||
<Autocomplete
|
||||
options={options}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={onSelect}
|
||||
status={status}
|
||||
/>
|
||||
</Animated.View>
|
||||
</PopoverFrame>
|
||||
</KeyboardTranslateView>
|
||||
</Portal>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface PopoverFrameProps {
|
||||
anchorY: number;
|
||||
reservedLift: DerivedValue<number>;
|
||||
topInset: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds the popover's height to the space above the anchor. It mounts only with a measured
|
||||
* anchor: Reanimated computes the first style when the hook first runs and sends later values
|
||||
* after the mounting commit, which native paints. A bound computed before the measurement painted
|
||||
* the popover at its minimum height, hanging over the composer, for a frame on every open.
|
||||
*/
|
||||
function PopoverFrame({
|
||||
anchorY,
|
||||
reservedLift,
|
||||
topInset,
|
||||
children,
|
||||
}: PopoverFrameProps): ReactElement {
|
||||
"use no memo";
|
||||
// React Compiler memoizes effect captures by reading SharedValue.value during render.
|
||||
const heightStyle = useAnimatedStyle(
|
||||
() => ({
|
||||
maxHeight: Math.max(0, anchorY - reservedLift.value - topInset - OFFSET_FROM_ANCHOR * 2),
|
||||
}),
|
||||
[anchorY, reservedLift, topInset],
|
||||
);
|
||||
return (
|
||||
<Animated.View testID="composer-autocomplete-popover" style={heightStyle}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ export const PinOff = StubIcon;
|
||||
export const Play = StubIcon;
|
||||
export const Plug = StubIcon;
|
||||
export const Plus = StubIcon;
|
||||
export const Puzzle = StubIcon;
|
||||
export const QrCode = StubIcon;
|
||||
export const RefreshCcw = StubIcon;
|
||||
export const RefreshCw = StubIcon;
|
||||
@@ -100,6 +101,7 @@ export const Smartphone = StubIcon;
|
||||
export const Split = StubIcon;
|
||||
export const Square = StubIcon;
|
||||
export const SquarePen = StubIcon;
|
||||
export const SquareSlash = StubIcon;
|
||||
export const SquareTerminal = StubIcon;
|
||||
export const Star = StubIcon;
|
||||
export const Sun = StubIcon;
|
||||
|
||||
@@ -35,6 +35,26 @@ export default defineConfig({
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reanimated-web-style-helpers",
|
||||
transform(_source, id) {
|
||||
// Reanimated writes animated styles to the DOM through react-native-web helpers it loads
|
||||
// with `require` inside try/catch. Vite serves ESM, so the require throws, the helpers stay
|
||||
// undefined, and the first animated style update after mount crashes the component.
|
||||
if (
|
||||
!id.split("?")[0].endsWith("/lib/module/ReanimatedModule/js-reanimated/webUtils.web.js")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
code: [
|
||||
'export { default as createReactDOMStyle } from "react-native-web/dist/exports/StyleSheet/compiler/createReactDOMStyle";',
|
||||
'export { createTransformValue, createTextShadowValue } from "react-native-web/dist/exports/StyleSheet/preprocess";',
|
||||
].join("\n"),
|
||||
map: null,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "compile-react-components",
|
||||
enforce: "pre",
|
||||
@@ -187,6 +207,9 @@ export default defineConfig({
|
||||
"react/jsx-dev-runtime",
|
||||
"react-native-gesture-handler > hoist-non-react-statics",
|
||||
"react-native-gesture-handler > invariant",
|
||||
// Loaded by the reanimated-web-style-helpers plugin, which the dependency scan does not see.
|
||||
"react-native-web/dist/exports/StyleSheet/compiler/createReactDOMStyle",
|
||||
"react-native-web/dist/exports/StyleSheet/preprocess",
|
||||
],
|
||||
exclude: [
|
||||
"react-native-enriched-markdown",
|
||||
|
||||
Reference in New Issue
Block a user