mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-14 20:35:02 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31b22b4497 | ||
|
|
dafc9e470b | ||
|
|
5d5f88a5cf | ||
|
|
2235d3513f | ||
|
|
f5499fd336 | ||
|
|
a9bc26bed2 | ||
|
|
3503edc65b | ||
|
|
9dedc14bbb | ||
|
|
11c28a48ea |
+30
-6
@@ -7,12 +7,12 @@ canonical files and are trying to add or change one.
|
||||
|
||||
## Canonical files
|
||||
|
||||
| File | Use case |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------- |
|
||||
| `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/hover-card.tsx` | Desktop-web hover card the pointer can move onto; Portal |
|
||||
| `components/ui/autocomplete-popover.tsx` | Slash-command autocomplete anchored to the focused composer input |
|
||||
| File | Use case |
|
||||
| ------------------------------------- | ----------------------------------------------------------------- |
|
||||
| `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/hover-card.tsx` | Desktop-web hover card the pointer can move onto; Portal |
|
||||
| `components/ui/autocomplete-popover/` | Slash-command autocomplete anchored to the focused composer input |
|
||||
|
||||
Each handles a different mix of concerns: combobox owns input focus, tooltip is
|
||||
non-interactive, hover-card is web-only desktop, autocomplete keeps the composer
|
||||
@@ -280,6 +280,22 @@ popover or leaves it behind the keyboard. `measureAtRest` measures through
|
||||
Fabric's transform-free `measureLayout` instead. Re-measure when `isMoving`
|
||||
settles; the composer's resting position changes as the draft grows.
|
||||
|
||||
A measured rect is an asynchronous copy, so it lands after the anchor's new
|
||||
layout has painted. The composer resizes while the autocomplete is open: a
|
||||
wrapped line, cleared text, a window resize. On web a `ResizeObserver` reads
|
||||
the anchor's rect against the layer and commits the placement with `flushSync`,
|
||||
which runs after layout and before paint. On native a Reanimated frame callback
|
||||
reads only the anchor's height on the UI thread and holds its bottom edge at
|
||||
the rest measurement. A height carries no transform. On iOS the absolute
|
||||
position does not reliably include one: the keyboard lift runs on the native
|
||||
driver and reaches the shadow tree only when the view re-renders. The
|
||||
fixed-bottom model is wrong for the centered New workspace composer on tablets,
|
||||
which grows around its middle, and for width or x changes. The popover is off
|
||||
there by half the growth, or by the old width, until the next rest measurement.
|
||||
Keep the portal's `KeyboardTranslateView` host-sized with
|
||||
`pointerEvents="box-none"` and position the popover inside it, so the popover
|
||||
moves without leaving its parent's bounds (Gotcha 1).
|
||||
|
||||
The provider also reconciles iOS from the controller's native `onEnd` event.
|
||||
The controller's stock iOS shared values update at move start and during an
|
||||
interactive move, but not at the terminal event, so JS contention can otherwise
|
||||
@@ -453,6 +469,14 @@ Before you write a new one, ask:
|
||||
`top` paints over its anchor until the next placement lands, about 150 ms
|
||||
on Android. Web hides this because React commits the resize and the
|
||||
re-placement in one task.
|
||||
7. **Does the panel hold a list that reads away from its anchor?** Make the
|
||||
anchor edge the list's resting scroll position: an inverted list, as in
|
||||
`components/ui/autocomplete.tsx`. On Android a scroll command issued after
|
||||
the rows commit reaches the screen one or two frames after them, so a list
|
||||
scrolled to its anchor edge paints its far end first. Give each result set
|
||||
its own list with a `key`, so it starts at the anchor edge with the rows
|
||||
that fill the panel. A list that mounts every result holds the panel's
|
||||
first frame until the last row is ready.
|
||||
|
||||
Then copy the closest canonical file and trim.
|
||||
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
import React, { act, type ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
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 { RetainedPanel } from "@/components/retained-panel";
|
||||
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 must paint its first frame at its place. On native, Reanimated computes a style's
|
||||
* first value when its hook first runs and sends later values after the mounting commit, which
|
||||
* native paints: a bound computed while the popover was closed hung the popover over the composer
|
||||
* for a frame on every open. The frame now mounts only with a measured anchor. This suite runs the
|
||||
* web frame; the native frame was checked frame by frame on Android.
|
||||
*/
|
||||
|
||||
const SAFE_AREA = {
|
||||
frame: { x: 0, y: 0, width: 390, height: 844 },
|
||||
insets: { top: 0, left: 0, right: 0, bottom: 0 },
|
||||
};
|
||||
// The composer rests on the dock's bottom edge and grows upward, as in the app.
|
||||
const COMPOSER_BOTTOM = 124;
|
||||
const OFFSET_FROM_ANCHOR = 12;
|
||||
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 styleCache = new Map<string, object>();
|
||||
function cachedStyle<T extends object>(key: string, create: () => T): T {
|
||||
const cached = styleCache.get(key);
|
||||
if (cached) return cached as T;
|
||||
const style = create();
|
||||
styleCache.set(key, style);
|
||||
return style;
|
||||
}
|
||||
function composerStyle(width: number): object {
|
||||
return cachedStyle(`composer:${width}`, () => ({
|
||||
position: "absolute" as const,
|
||||
bottom: COMPOSER_BOTTOM,
|
||||
left: 16,
|
||||
width,
|
||||
}));
|
||||
}
|
||||
function inputStyle(height: number): object {
|
||||
return cachedStyle(`input:${height}`, () => ({ height }));
|
||||
}
|
||||
function ignoreSelection(): void {}
|
||||
|
||||
function Providers({ children }: { children: ReactNode }): ReactNode {
|
||||
return (
|
||||
<SafeAreaProvider initialMetrics={SAFE_AREA}>
|
||||
<KeyboardProvider>
|
||||
<KeyboardShiftProvider>
|
||||
<PortalProvider>{children}</PortalProvider>
|
||||
</KeyboardShiftProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface ScreenProps {
|
||||
visible: boolean;
|
||||
panelActive?: boolean;
|
||||
inputHeight?: number;
|
||||
composerWidth?: number;
|
||||
options?: AutocompleteOption[];
|
||||
/** Counted up from the best match, which is the last option. */
|
||||
selectedFromBest?: number;
|
||||
}
|
||||
|
||||
function Screen({
|
||||
visible,
|
||||
panelActive = true,
|
||||
inputHeight = 120,
|
||||
composerWidth = 358,
|
||||
options = OPTIONS,
|
||||
selectedFromBest = 0,
|
||||
}: ScreenProps): ReactNode {
|
||||
return (
|
||||
<Providers>
|
||||
<RetainedPanel active={panelActive}>
|
||||
<View style={SCREEN_STYLE}>
|
||||
<View style={composerStyle(composerWidth)}>
|
||||
<AutocompletePopover
|
||||
visible={visible}
|
||||
options={options}
|
||||
selectedIndex={options.length - 1 - selectedFromBest}
|
||||
onSelect={ignoreSelection}
|
||||
>
|
||||
<View testID="composer-input" style={inputStyle(inputHeight)} />
|
||||
</AutocompletePopover>
|
||||
</View>
|
||||
<FloatingPanelPortalHost />
|
||||
</View>
|
||||
</RetainedPanel>
|
||||
</Providers>
|
||||
);
|
||||
}
|
||||
|
||||
function render(node: ReactNode): void {
|
||||
if (!container) {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
}
|
||||
act(() => root?.render(node));
|
||||
}
|
||||
|
||||
/** Commits a change to the screen synchronously and lets the browser paint it. */
|
||||
async function changeAndPaint(node: ReactNode): Promise<void> {
|
||||
flushSync(() => root?.render(node));
|
||||
await new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
const channel = new MessageChannel();
|
||||
channel.port1.addEventListener("message", () => resolve(), { once: true });
|
||||
channel.port1.start();
|
||||
channel.port2.postMessage(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function rectOf(testID: string): DOMRect | null {
|
||||
return document.querySelector(`[data-testid="${testID}"]`)?.getBoundingClientRect() ?? null;
|
||||
}
|
||||
|
||||
/** Where the popover sits against the composer, or null when it is not painted. */
|
||||
function placementAgainstComposer(): { gap: number; x: number; width: number } | null {
|
||||
const popover = rectOf("composer-autocomplete-popover");
|
||||
const composer = rectOf("composer-input");
|
||||
if (!popover || !composer) return null;
|
||||
return {
|
||||
gap: Math.round(composer.top - popover.bottom),
|
||||
x: Math.round(popover.x - composer.x),
|
||||
width: Math.round(popover.width - composer.width),
|
||||
};
|
||||
}
|
||||
|
||||
async function openSettled(props: Omit<ScreenProps, "visible"> = {}): Promise<void> {
|
||||
render(<Screen visible={false} {...props} />);
|
||||
render(<Screen visible {...props} />);
|
||||
await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
expect(placementAgainstComposer()).toEqual({ gap: OFFSET_FROM_ANCHOR, x: 0, width: 0 });
|
||||
}
|
||||
|
||||
/** 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);
|
||||
});
|
||||
|
||||
/**
|
||||
* The popover lives in a portal and copies the composer's rect. A copy taken after the composer's
|
||||
* layout paints the composer at its new size against the popover's old place: the popover overlapped
|
||||
* a composer that grew a line, floated above one that shrank, and kept the old width after a resize.
|
||||
*/
|
||||
it("moves with the composer in the frame the composer grows", async () => {
|
||||
await openSettled();
|
||||
await changeAndPaint(<Screen visible inputHeight={160} />);
|
||||
expect(placementAgainstComposer()).toEqual({ gap: OFFSET_FROM_ANCHOR, x: 0, width: 0 });
|
||||
});
|
||||
|
||||
it("follows the composer down when its text clears before the popover closes", async () => {
|
||||
// The input relayouts on the cleared text before the autocomplete hides the popover.
|
||||
await openSettled({ inputHeight: 160 });
|
||||
await changeAndPaint(<Screen visible inputHeight={120} />);
|
||||
expect(placementAgainstComposer()).toEqual({ gap: OFFSET_FROM_ANCHOR, x: 0, width: 0 });
|
||||
});
|
||||
|
||||
it("matches the composer's width in the frame the composer resizes", async () => {
|
||||
await openSettled();
|
||||
await changeAndPaint(<Screen visible composerWidth={240} />);
|
||||
expect(placementAgainstComposer()).toEqual({ gap: OFFSET_FROM_ANCHOR, x: 0, width: 0 });
|
||||
});
|
||||
|
||||
/**
|
||||
* A retained workspace stays mounted while another route shows. A popover left open there tracked a
|
||||
* composer it could not measure, and on native stopped following the composer after the user came
|
||||
* back.
|
||||
*/
|
||||
it("closes while its retained panel is hidden and reopens in place when it shows again", async () => {
|
||||
await openSettled();
|
||||
render(<Screen visible panelActive={false} />);
|
||||
expect(rectOf("composer-autocomplete-popover")).toBeNull();
|
||||
render(<Screen visible />);
|
||||
await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
expect(placementAgainstComposer()).toEqual({ gap: OFFSET_FROM_ANCHOR, x: 0, width: 0 });
|
||||
});
|
||||
|
||||
/** As many commands as a workspace with skills installed lists; the best match is last. */
|
||||
function commandOptions(prefix: string, count: number): AutocompleteOption[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `${prefix}-${count - index}`,
|
||||
label: `/${prefix}-${count - index}`,
|
||||
icon: "skill" as const,
|
||||
description: `Description of ${prefix} ${count - index}`,
|
||||
tag: "Skill",
|
||||
}));
|
||||
}
|
||||
|
||||
const LIST_PADDING_AND_BORDER = 5;
|
||||
const ROW_HEIGHT_AT_LEAST = 36;
|
||||
|
||||
/** How far the best match's row sits above the popover's bottom edge, or null when not painted. */
|
||||
function bestMatchAgainstPopoverBottom(label: string): number | null {
|
||||
const popover = rectOf("composer-autocomplete-popover");
|
||||
const row = document.querySelector(`[aria-label^="${label},"]`)?.getBoundingClientRect();
|
||||
if (!popover) return null;
|
||||
if (!row) return Number.NaN;
|
||||
return Math.round(popover.bottom - row.bottom);
|
||||
}
|
||||
|
||||
/** Samples a reading after every paint until stopped. */
|
||||
function recordPaintedFrames<T>(read: () => T): { frames: T[]; stop: () => void } {
|
||||
const frames: T[] = [];
|
||||
let running = true;
|
||||
const channel = new MessageChannel();
|
||||
channel.port1.addEventListener("message", () => {
|
||||
if (running) frames.push(read());
|
||||
});
|
||||
channel.port1.start();
|
||||
const tick = (): void => {
|
||||
if (!running) return;
|
||||
channel.port2.postMessage(null);
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
return {
|
||||
frames,
|
||||
stop: () => {
|
||||
running = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The list is longer than the popover and reads upward from its best match, which sits against the
|
||||
* bottom edge. Scrolling there after the list mounted painted the list at its old offset first: a
|
||||
* few pixels short on open, a different stretch of the list when the results changed.
|
||||
*/
|
||||
it("paints a long list with its best match against the bottom from the first frame", async () => {
|
||||
const options = commandOptions("skill", 100);
|
||||
render(<Screen visible={false} options={options} />);
|
||||
const recording = recordPaintedFrames(() => bestMatchAgainstPopoverBottom("/skill-1"));
|
||||
render(<Screen visible options={options} />);
|
||||
await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
recording.stop();
|
||||
|
||||
const painted = recording.frames.filter((frame) => frame !== null);
|
||||
expect(painted.length).toBeGreaterThan(0);
|
||||
expect([...new Set(painted)]).toEqual([LIST_PADDING_AND_BORDER]);
|
||||
});
|
||||
|
||||
it("keeps the best match against the bottom in the frame the results change", async () => {
|
||||
const all = commandOptions("skill", 100);
|
||||
const fewer = all.filter((_, index) => index % 3 === 0);
|
||||
render(<Screen visible={false} options={all} />);
|
||||
render(<Screen visible options={all} />);
|
||||
await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
// Narrowing keeps `/skill-1` the best match; widening brings the rest back above it.
|
||||
const recording = recordPaintedFrames(() => bestMatchAgainstPopoverBottom("/skill-1"));
|
||||
await changeAndPaint(<Screen visible options={fewer} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
await changeAndPaint(<Screen visible options={all} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
recording.stop();
|
||||
|
||||
expect([...new Set(recording.frames)]).toEqual([LIST_PADDING_AND_BORDER]);
|
||||
});
|
||||
|
||||
function mountedRows(prefix = "skill"): Element[] {
|
||||
return [...document.querySelectorAll(`[aria-label^="/${prefix}-"]`)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every result used to mount before the popover's first frame, so a workspace with a hundred
|
||||
* commands waited on a hundred rows to see six.
|
||||
*/
|
||||
it("mounts the rows nearest the composer first, however many results there are", async () => {
|
||||
const options = commandOptions("skill", 500);
|
||||
render(<Screen visible={false} options={options} />);
|
||||
const recording = recordPaintedFrames(() =>
|
||||
rectOf("composer-autocomplete-popover") ? mountedRows().length : null,
|
||||
);
|
||||
render(<Screen visible options={options} />);
|
||||
await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
recording.stop();
|
||||
|
||||
const firstFrame = recording.frames.find((frame) => frame !== null);
|
||||
expect(firstFrame).toBeGreaterThan(0);
|
||||
expect(firstFrame).toBeLessThan(20);
|
||||
expect(bestMatchAgainstPopoverBottom("/skill-1")).toBe(LIST_PADDING_AND_BORDER);
|
||||
});
|
||||
|
||||
/**
|
||||
* A list that had mounted its whole window kept that window for the next results, so narrowing a
|
||||
* long list to another long one waited on ten times the rows the popover shows.
|
||||
*/
|
||||
it("mounts the rows nearest the composer first when the results change", async () => {
|
||||
const commands = commandOptions("skill", 500);
|
||||
const matches = commandOptions("plugin", 300);
|
||||
render(<Screen visible={false} options={commands} />);
|
||||
render(<Screen visible options={commands} />);
|
||||
await waitForPopover();
|
||||
await expect.poll(() => mountedRows().length, { timeout: 3000 }).toBeGreaterThan(40);
|
||||
|
||||
const recording = recordPaintedFrames(() => mountedRows("plugin").length);
|
||||
await changeAndPaint(<Screen visible options={matches} />);
|
||||
recording.stop();
|
||||
|
||||
const firstFrame = recording.frames.find((count) => count > 0);
|
||||
expect(firstFrame).toBeLessThan(20);
|
||||
expect(bestMatchAgainstPopoverBottom("/plugin-1")).toBe(LIST_PADDING_AND_BORDER);
|
||||
});
|
||||
|
||||
function scrollerOf(popover: HTMLElement): HTMLElement {
|
||||
const scroller = [...popover.querySelectorAll<HTMLElement>("*")].find(
|
||||
(element) =>
|
||||
element.scrollHeight > element.clientHeight + 1 &&
|
||||
["auto", "scroll"].includes(getComputedStyle(element).overflowY),
|
||||
);
|
||||
if (!scroller) throw new Error("The list does not scroll");
|
||||
return scroller;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native paints a scroll command a frame or two after the rows it was meant for. A list that rests
|
||||
* against the composer only because script scrolled it there shows the rows somewhere else first,
|
||||
* so the composer's edge is where the list's scroll starts.
|
||||
*/
|
||||
it("rests against the composer where its scroll starts", async () => {
|
||||
const all = commandOptions("skill", 100);
|
||||
const fewer = all.filter((_, index) => index % 3 === 0);
|
||||
render(<Screen visible={false} options={all} />);
|
||||
render(<Screen visible options={all} />);
|
||||
const popover = await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
expect(scrollerOf(popover).scrollTop).toBe(0);
|
||||
|
||||
await changeAndPaint(<Screen visible options={fewer} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
expect(scrollerOf(popover).scrollTop).toBe(0);
|
||||
|
||||
await changeAndPaint(<Screen visible options={all} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
expect(scrollerOf(popover).scrollTop).toBe(0);
|
||||
expect(bestMatchAgainstPopoverBottom("/skill-1")).toBe(LIST_PADDING_AND_BORDER);
|
||||
});
|
||||
|
||||
it("returns to the best match in the frame results arrive while the list is scrolled away", async () => {
|
||||
const all = commandOptions("skill", 100);
|
||||
const fewer = all.filter((_, index) => index % 3 === 0);
|
||||
render(<Screen visible={false} options={all} />);
|
||||
render(<Screen visible options={all} />);
|
||||
const popover = await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
scrollerOf(popover).scrollTop = 400;
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
expect(bestMatchAgainstPopoverBottom("/skill-1")).not.toBe(LIST_PADDING_AND_BORDER);
|
||||
|
||||
const recording = recordPaintedFrames(() => bestMatchAgainstPopoverBottom("/skill-1"));
|
||||
await changeAndPaint(<Screen visible options={fewer} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
recording.stop();
|
||||
|
||||
expect([...new Set(recording.frames)]).toEqual([LIST_PADDING_AND_BORDER]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The composer hands over a new list on every keystroke and every refetch, often holding the results
|
||||
* it already showed. Starting over for those returned a list the user had scrolled to its rest.
|
||||
*/
|
||||
it("keeps its place when the same results arrive again", async () => {
|
||||
const options = commandOptions("skill", 100);
|
||||
render(<Screen visible={false} options={options} />);
|
||||
render(<Screen visible options={options} />);
|
||||
const popover = await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
scrollerOf(popover).scrollTop = 400;
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
const scrolledAway = bestMatchAgainstPopoverBottom("/skill-1");
|
||||
expect(scrolledAway).not.toBe(LIST_PADDING_AND_BORDER);
|
||||
|
||||
const recording = recordPaintedFrames(() => bestMatchAgainstPopoverBottom("/skill-1"));
|
||||
await changeAndPaint(<Screen visible options={commandOptions("skill", 100)} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
recording.stop();
|
||||
|
||||
expect([...new Set(recording.frames)]).toEqual([scrolledAway]);
|
||||
});
|
||||
|
||||
/** Whether the row is wholly inside the popover's box. */
|
||||
function isRowInView(label: string): boolean {
|
||||
const popover = rectOf("composer-autocomplete-popover");
|
||||
const row = document.querySelector(`[aria-label^="${label},"]`)?.getBoundingClientRect();
|
||||
if (!popover || !row) return false;
|
||||
return row.top >= popover.top - 0.5 && row.bottom <= popover.bottom + 0.5;
|
||||
}
|
||||
|
||||
it("reveals the row the arrow keys select, including across the wrap to the far end", async () => {
|
||||
const options = commandOptions("skill", 100);
|
||||
render(<Screen visible={false} options={options} />);
|
||||
render(<Screen visible options={options} />);
|
||||
await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
for (const selectedFromBest of [1, 5, 6, 7, 12, 6, 0, 99, 98, 93, 0]) {
|
||||
render(<Screen visible options={options} selectedFromBest={selectedFromBest} />);
|
||||
// Rows beyond the mounted ones come into view as they mount.
|
||||
await expect
|
||||
.poll(() => [selectedFromBest, isRowInView(`/skill-${selectedFromBest + 1}`)], {
|
||||
timeout: 3000,
|
||||
})
|
||||
.toEqual([selectedFromBest, true]);
|
||||
}
|
||||
await expect.poll(() => bestMatchAgainstPopoverBottom("/skill-1")).toBe(LIST_PADDING_AND_BORDER);
|
||||
});
|
||||
|
||||
/** The composer grew until the space above it holds fewer rows than the popover was showing. */
|
||||
it("keeps the selected row in view when the popover gets shorter", async () => {
|
||||
const options = commandOptions("skill", 100);
|
||||
render(<Screen visible={false} options={options} />);
|
||||
render(<Screen visible options={options} />);
|
||||
await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
// Beyond the rows the popover opened with, so it came into view at the popover's top edge.
|
||||
render(<Screen visible options={options} selectedFromBest={7} />);
|
||||
await expect.poll(() => isRowInView("/skill-8"), { timeout: 3000 }).toBe(true);
|
||||
const height = rectOf("composer-autocomplete-popover")!.height;
|
||||
|
||||
render(<Screen visible options={options} selectedFromBest={7} inputHeight={560} />);
|
||||
await expect
|
||||
.poll(() => rectOf("composer-autocomplete-popover")!.height, { timeout: 3000 })
|
||||
.toBeLessThan(height - ROW_HEIGHT_AT_LEAST);
|
||||
await expect.poll(() => isRowInView("/skill-8"), { timeout: 3000 }).toBe(true);
|
||||
});
|
||||
|
||||
/** The label of the row at the top of the popover's box. */
|
||||
function topRowInView(): string | null {
|
||||
const popover = rectOf("composer-autocomplete-popover");
|
||||
if (!popover) return null;
|
||||
const rows = mountedRows()
|
||||
.map((row) => ({ rect: row.getBoundingClientRect(), label: row.getAttribute("aria-label") }))
|
||||
.filter(({ rect }) => rect.top >= popover.top - 0.5 && rect.bottom <= popover.bottom + 0.5)
|
||||
.toSorted((a, b) => a.rect.top - b.rect.top);
|
||||
return rows[0]?.label?.split(",")[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The far end of a long list is not mounted while the list rests against the composer. Scrolling to
|
||||
* it as rows mounted painted the list at several places on the way.
|
||||
*/
|
||||
it("jumps between the two ends in one frame when the arrow keys wrap", async () => {
|
||||
const options = commandOptions("skill", 100);
|
||||
render(<Screen visible={false} options={options} />);
|
||||
render(<Screen visible options={options} />);
|
||||
await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const restingAtComposer = topRowInView();
|
||||
|
||||
const toFarEnd = recordPaintedFrames(topRowInView);
|
||||
await changeAndPaint(<Screen visible options={options} selectedFromBest={99} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
toFarEnd.stop();
|
||||
expect([...new Set(toFarEnd.frames)]).toEqual(["/skill-100"]);
|
||||
expect(isRowInView("/skill-100")).toBe(true);
|
||||
|
||||
const toComposer = recordPaintedFrames(topRowInView);
|
||||
await changeAndPaint(<Screen visible options={options} selectedFromBest={0} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
toComposer.stop();
|
||||
expect([...new Set(toComposer.frames)]).toEqual([restingAtComposer]);
|
||||
expect(bestMatchAgainstPopoverBottom("/skill-1")).toBe(LIST_PADDING_AND_BORDER);
|
||||
});
|
||||
|
||||
it("scrolls towards rows further from the composer when the wheel turns up", async () => {
|
||||
const options = commandOptions("skill", 100);
|
||||
render(<Screen visible={false} options={options} />);
|
||||
render(<Screen visible options={options} />);
|
||||
const popover = await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
const row = document.querySelector('[aria-label^="/skill-1,"]');
|
||||
row?.dispatchEvent(new WheelEvent("wheel", { deltaY: -120, bubbles: true, cancelable: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// The best match moved down, out of the popover, by the distance the wheel turned.
|
||||
expect(bestMatchAgainstPopoverBottom("/skill-1")).toBe(LIST_PADDING_AND_BORDER - 120);
|
||||
expect(isRowInView("/skill-7")).toBe(true);
|
||||
expect(popover.getBoundingClientRect().bottom).toBe(rectOf("composer-input")!.top - 12);
|
||||
});
|
||||
+44
-58
@@ -1,15 +1,13 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactElement, type ReactNode } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { View, useWindowDimensions, type StyleProp, type ViewStyle } from "react-native";
|
||||
StyleSheet,
|
||||
View,
|
||||
useWindowDimensions,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import Animated, { useAnimatedReaction, useAnimatedStyle } from "react-native-reanimated";
|
||||
import { useAnimatedReaction, useAnimatedRef } from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import {
|
||||
@@ -21,18 +19,10 @@ import {
|
||||
measureFloatingPanelPortalHost,
|
||||
useFloatingPanelPortalHostName,
|
||||
} from "@/components/ui/floating-panel-portal";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { KeyboardTranslateView, useKeyboardAnchor, useKeyboardShift } from "@/keyboard/shift";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
|
||||
const OFFSET_FROM_ANCHOR = SPACING[3];
|
||||
|
||||
interface RelativeAnchorRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
hostHeight: number;
|
||||
}
|
||||
import { PopoverFrame } from "./internal/frame";
|
||||
import type { AnchorAtRest } from "./internal/placement";
|
||||
|
||||
interface AutocompletePopoverProps {
|
||||
visible: boolean;
|
||||
@@ -55,8 +45,16 @@ export function AutocompletePopover({
|
||||
}: AutocompletePopoverProps): ReactElement | null {
|
||||
"use no memo";
|
||||
// React Compiler memoizes effect captures by reading SharedValue.value during render.
|
||||
const anchorRef = useRef<View>(null);
|
||||
const [relativeAnchorRect, setRelativeAnchorRect] = useState<RelativeAnchorRect | null>(null);
|
||||
const anchorRef = useAnimatedRef<View>();
|
||||
// The animated ref returns its native handle, which React 19 would call as a ref cleanup.
|
||||
// The anchor stays a plain View: its style is the caller's Unistyles style.
|
||||
const setAnchorElement = useCallback(
|
||||
(element: View | null) => {
|
||||
anchorRef(element);
|
||||
},
|
||||
[anchorRef],
|
||||
);
|
||||
const [anchorAtRest, setAnchorAtRest] = useState<AnchorAtRest | null>(null);
|
||||
const windowDimensions = useWindowDimensions();
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
const portalHostName = useFloatingPanelPortalHostName();
|
||||
@@ -64,7 +62,9 @@ export function AutocompletePopover({
|
||||
const anchor = useKeyboardAnchor();
|
||||
const { measureAtRest } = anchor;
|
||||
const measurementGeneration = useRef(0);
|
||||
const canMeasure = visible && (options.length === 0 || selectedIndex >= 0);
|
||||
// A retained panel stays mounted while hidden; its anchor cannot be measured until it shows.
|
||||
const isPanelActive = useRetainedPanelActive();
|
||||
const canMeasure = isPanelActive && visible && (options.length === 0 || selectedIndex >= 0);
|
||||
|
||||
const remeasure = useCallback(() => {
|
||||
if (!canMeasure) return;
|
||||
@@ -78,10 +78,11 @@ export function AutocompletePopover({
|
||||
if (generation !== measurementGeneration.current || !anchorRect || !hostRect) {
|
||||
return undefined;
|
||||
}
|
||||
setRelativeAnchorRect({
|
||||
setAnchorAtRest({
|
||||
x: anchorRect.x - hostRect.x,
|
||||
y: anchorRect.y - hostRect.y,
|
||||
width: anchorRect.width,
|
||||
height: anchorRect.height,
|
||||
hostHeight: hostRect.height,
|
||||
});
|
||||
return undefined;
|
||||
@@ -91,7 +92,7 @@ export function AutocompletePopover({
|
||||
useEffect(() => {
|
||||
measurementGeneration.current += 1;
|
||||
if (!canMeasure) {
|
||||
setRelativeAnchorRect(null);
|
||||
setAnchorAtRest(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -114,47 +115,32 @@ export function AutocompletePopover({
|
||||
[isMoving, remeasure],
|
||||
);
|
||||
|
||||
// Lay out at the anchor's resting position; the KeyboardTranslateView below lifts
|
||||
// it with the anchor. Height changes only at keyboard boundaries.
|
||||
const baseStyle = useMemo(() => {
|
||||
if (!relativeAnchorRect) return null;
|
||||
return inlineUnistylesStyle({
|
||||
position: "absolute" as const,
|
||||
left: relativeAnchorRect.x,
|
||||
width: relativeAnchorRect.width,
|
||||
bottom: relativeAnchorRect.hostHeight - relativeAnchorRect.y + OFFSET_FROM_ANCHOR,
|
||||
});
|
||||
}, [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.
|
||||
// The layer covers the host and rides the anchor's keyboard translation; the frame inside it
|
||||
// places the popover. A layer that spans the host keeps the popover inside its parent's bounds,
|
||||
// which Android hit-testing requires.
|
||||
return (
|
||||
<View ref={anchorRef} onLayout={remeasure} style={anchorStyle} collapsable={false}>
|
||||
<View ref={setAnchorElement} onLayout={remeasure} style={anchorStyle} collapsable={false}>
|
||||
{children}
|
||||
{showPopover ? (
|
||||
{canMeasure && anchorAtRest ? (
|
||||
<Portal hostName={portalHostName}>
|
||||
<KeyboardTranslateView {...anchor.translation} style={baseStyle}>
|
||||
<Animated.View testID="composer-autocomplete-popover" style={heightStyle}>
|
||||
<KeyboardTranslateView
|
||||
{...anchor.translation}
|
||||
style={StyleSheet.absoluteFill}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<PopoverFrame
|
||||
anchorRef={anchorRef}
|
||||
anchorAtRest={anchorAtRest}
|
||||
reservedLift={anchor.reservedLift}
|
||||
topInset={safeAreaInsets.top}
|
||||
>
|
||||
<Autocomplete
|
||||
options={options}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={onSelect}
|
||||
status={status}
|
||||
/>
|
||||
</Animated.View>
|
||||
</PopoverFrame>
|
||||
</KeyboardTranslateView>
|
||||
</Portal>
|
||||
) : null}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useCallback, type ReactElement } from "react";
|
||||
import Animated, {
|
||||
measure,
|
||||
useAnimatedStyle,
|
||||
useFrameCallback,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { placeAboveAnchor, type PopoverFrameProps } from "./placement";
|
||||
|
||||
/**
|
||||
* The composer grows upward from a fixed bottom edge as its text wraps. Its height is read on the
|
||||
* UI thread every frame, so the popover moves in the frame the composer's new layout mounts; an
|
||||
* asynchronous measurement through JS painted the composer over or away from the popover first.
|
||||
*
|
||||
* The frame 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 placement
|
||||
* computed before the measurement painted the popover over the composer for a frame on every open.
|
||||
*/
|
||||
export function PopoverFrame({
|
||||
anchorRef,
|
||||
anchorAtRest,
|
||||
reservedLift,
|
||||
topInset,
|
||||
children,
|
||||
}: PopoverFrameProps): ReactElement {
|
||||
"use no memo";
|
||||
// React Compiler memoizes effect captures by reading SharedValue.value during render.
|
||||
const anchorHeight = useSharedValue(anchorAtRest.height);
|
||||
const anchorLost = useSharedValue(false);
|
||||
const readAnchorHeight = useCallback(() => {
|
||||
"worklet";
|
||||
if (anchorLost.value) return;
|
||||
const measured = measure(anchorRef);
|
||||
if (!measured) {
|
||||
// The anchor left the shadow tree, usually as the composer unmounts; the portal removes the
|
||||
// frame a commit later. Each failed measure() logs a warning, and a busy JS thread holds
|
||||
// that commit for many frames, so stop measuring and follow the rest measurement.
|
||||
anchorLost.value = true;
|
||||
return;
|
||||
}
|
||||
if (measured.height !== anchorHeight.value) {
|
||||
anchorHeight.value = measured.height;
|
||||
}
|
||||
}, [anchorHeight, anchorLost, anchorRef]);
|
||||
useFrameCallback(readAnchorHeight);
|
||||
|
||||
const style = useAnimatedStyle(() => {
|
||||
const anchorBottom = anchorAtRest.y + anchorAtRest.height;
|
||||
const liveHeight = anchorLost.value ? anchorAtRest.height : anchorHeight.value;
|
||||
return {
|
||||
position: "absolute",
|
||||
left: anchorAtRest.x,
|
||||
width: anchorAtRest.width,
|
||||
...placeAboveAnchor({
|
||||
anchorTop: anchorBottom - liveHeight,
|
||||
hostHeight: anchorAtRest.hostHeight,
|
||||
reservedLift: reservedLift.value,
|
||||
topInset,
|
||||
}),
|
||||
};
|
||||
}, [anchorAtRest, anchorHeight, anchorLost, reservedLift, topInset]);
|
||||
|
||||
return (
|
||||
<Animated.View testID="composer-autocomplete-popover" style={style}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactElement } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { View } from "react-native";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { placeAboveAnchor, type AnchorAtRest, type PopoverFrameProps } from "./placement";
|
||||
|
||||
type AnchorRect = Omit<AnchorAtRest, "height">;
|
||||
|
||||
function rectWithin(anchor: HTMLElement, host: HTMLElement): AnchorRect {
|
||||
const anchorRect = anchor.getBoundingClientRect();
|
||||
const hostRect = host.getBoundingClientRect();
|
||||
return {
|
||||
x: anchorRect.x - hostRect.x,
|
||||
y: anchorRect.y - hostRect.y,
|
||||
width: anchorRect.width,
|
||||
hostHeight: hostRect.height,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer resizes as its text wraps and as the window resizes. ResizeObserver callbacks run
|
||||
* after layout and before paint, so committing the new placement synchronously there paints the
|
||||
* popover against the composer's new rect in the same frame.
|
||||
*/
|
||||
export function PopoverFrame({
|
||||
anchorRef,
|
||||
anchorAtRest,
|
||||
reservedLift,
|
||||
topInset,
|
||||
children,
|
||||
}: PopoverFrameProps): ReactElement {
|
||||
"use no memo";
|
||||
// React Compiler memoizes effect captures by reading SharedValue.value during render.
|
||||
const frameRef = useRef<View>(null);
|
||||
const [anchor, setAnchor] = useState<AnchorRect>(anchorAtRest);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const anchorElement = anchorRef.current as unknown as HTMLElement | null;
|
||||
// The frame's parent is the portal layer, which covers the host.
|
||||
const host = (frameRef.current as unknown as HTMLElement | null)?.parentElement;
|
||||
if (!anchorElement || !host) return undefined;
|
||||
setAnchor(rectWithin(anchorElement, host));
|
||||
const observer = new ResizeObserver(() => {
|
||||
flushSync(() => setAnchor(rectWithin(anchorElement, host)));
|
||||
});
|
||||
observer.observe(anchorElement);
|
||||
observer.observe(host);
|
||||
return () => observer.disconnect();
|
||||
}, [anchorAtRest, anchorRef]);
|
||||
|
||||
const style = inlineUnistylesStyle({
|
||||
position: "absolute" as const,
|
||||
left: anchor.x,
|
||||
width: anchor.width,
|
||||
...placeAboveAnchor({
|
||||
anchorTop: anchor.y,
|
||||
hostHeight: anchor.hostHeight,
|
||||
reservedLift: reservedLift.value,
|
||||
topInset,
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<View ref={frameRef} testID="composer-autocomplete-popover" style={style}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { View } from "react-native";
|
||||
import type { AnimatedRef, DerivedValue } from "react-native-reanimated";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
|
||||
const OFFSET_FROM_ANCHOR = SPACING[3];
|
||||
|
||||
/** The anchor's resting rect, relative to the portal host the popover renders in. */
|
||||
export interface AnchorAtRest {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
hostHeight: number;
|
||||
}
|
||||
|
||||
export interface PopoverFrameProps {
|
||||
anchorRef: AnimatedRef<View>;
|
||||
/** Taken asynchronously; the frame follows the anchor's live size between measurements. */
|
||||
anchorAtRest: AnchorAtRest;
|
||||
reservedLift: DerivedValue<number>;
|
||||
topInset: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** Places the popover above an anchor whose top edge is `anchorTop` within the host. */
|
||||
export function placeAboveAnchor(input: {
|
||||
anchorTop: number;
|
||||
hostHeight: number;
|
||||
reservedLift: number;
|
||||
topInset: number;
|
||||
}): { bottom: number; maxHeight: number } {
|
||||
"worklet";
|
||||
return {
|
||||
bottom: input.hostHeight - input.anchorTop + OFFSET_FROM_ANCHOR,
|
||||
maxHeight: Math.max(
|
||||
0,
|
||||
input.anchorTop - input.reservedLift - input.topInset - OFFSET_FROM_ANCHOR * 2,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ScrollView,
|
||||
FlatList,
|
||||
Text,
|
||||
View,
|
||||
Pressable,
|
||||
type LayoutChangeEvent,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
type ListRenderItemInfo,
|
||||
type PressableStateCallbackType,
|
||||
type ViewToken,
|
||||
} from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -29,7 +28,6 @@ import { Button } from "./button";
|
||||
import { HighlightedText } from "./highlighted-text";
|
||||
import { mutedIconColorMapping } from "./icon-color";
|
||||
import { LoadingSpinner } from "./loading-spinner";
|
||||
import { getScrollOffsetToRevealItem } from "./scroll-reveal";
|
||||
import { StatusBadge } from "./status-badge";
|
||||
|
||||
export type AutocompleteOptionIcon =
|
||||
@@ -80,6 +78,8 @@ const ErrorIcon = withUnistyles(CircleAlert, mutedIconColorMapping);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, mutedIconColorMapping);
|
||||
|
||||
const ROW_ICON_SIZE = 14;
|
||||
const ROW_MIN_HEIGHT = 36;
|
||||
const DEFAULT_MAX_HEIGHT = 220;
|
||||
|
||||
const BOLT_GLYPH_PATTERN = /⚡|️/gu;
|
||||
|
||||
@@ -92,21 +92,17 @@ function removeBoltGlyphs(value?: string): string | undefined {
|
||||
}
|
||||
|
||||
interface AutocompleteRowProps {
|
||||
index: number;
|
||||
option: AutocompleteOption;
|
||||
isSelected: boolean;
|
||||
showArgumentHint: boolean;
|
||||
onSelect: (option: AutocompleteOption) => void;
|
||||
onRowLayout: (index: number, event: LayoutChangeEvent) => void;
|
||||
}
|
||||
|
||||
function AutocompleteRow({
|
||||
index,
|
||||
const AutocompleteRow = memo(function AutocompleteRow({
|
||||
option,
|
||||
isSelected,
|
||||
showArgumentHint,
|
||||
onSelect,
|
||||
onRowLayout,
|
||||
}: AutocompleteRowProps) {
|
||||
const label = removeBoltGlyphs(option.label) ?? option.label;
|
||||
const description = removeBoltGlyphs(option.description);
|
||||
@@ -114,10 +110,6 @@ function AutocompleteRow({
|
||||
const Icon = ROW_ICONS[option.icon];
|
||||
const labelMatches = label === option.label ? option.labelMatches : undefined;
|
||||
|
||||
const handleLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => onRowLayout(index, event),
|
||||
[index, onRowLayout],
|
||||
);
|
||||
const handlePress = useCallback(() => onSelect(option), [onSelect, option]);
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
@@ -129,7 +121,6 @@ function AutocompleteRow({
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onLayout={handleLayout}
|
||||
onPress={handlePress}
|
||||
style={pressableStyle}
|
||||
// Name and description only; the hint and tag repeat what the name implies.
|
||||
@@ -155,7 +146,7 @@ function AutocompleteRow({
|
||||
{option.tag ? <StatusBadge label={option.tag} size="xs" /> : null}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function AutocompleteStatusRow({ status }: { status: AutocompleteStatus }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -202,94 +193,212 @@ function AutocompleteStatusRow({ status }: { status: AutocompleteStatus }) {
|
||||
);
|
||||
}
|
||||
|
||||
type ListEnd = "composer" | "far";
|
||||
|
||||
/**
|
||||
* The scroller a list is shown in: one for each result set, resting at one end of it. A scroller
|
||||
* starts at its resting end with only the rows nearest it, so the list opens against the composer
|
||||
* and reaches the far end when the selection wraps there without mounting the rows in between.
|
||||
* A scroller that outlived its results would keep its scroll position and its mounted rows: on
|
||||
* native a scroll command reaches the screen after the rows it was meant for.
|
||||
*/
|
||||
function useScroller(
|
||||
options: readonly AutocompleteOption[],
|
||||
selectedIndex: number,
|
||||
): { key: string; restsAt: ListEnd } {
|
||||
const [scroller, setScroller] = useState<{
|
||||
options: readonly AutocompleteOption[];
|
||||
resultSet: number;
|
||||
restsAt: ListEnd;
|
||||
}>({ options, resultSet: 0, restsAt: "composer" });
|
||||
let next = scroller;
|
||||
if (next.options !== options) {
|
||||
next = isSameResultSet(next.options, options)
|
||||
? { ...next, options }
|
||||
: { options, resultSet: next.resultSet + 1, restsAt: "composer" };
|
||||
}
|
||||
const restsAt = endSelected(options, selectedIndex) ?? next.restsAt;
|
||||
if (restsAt !== next.restsAt) {
|
||||
next = { ...next, restsAt };
|
||||
}
|
||||
if (next !== scroller) {
|
||||
setScroller(next);
|
||||
}
|
||||
return { key: `${next.resultSet}:${next.restsAt}`, restsAt: next.restsAt };
|
||||
}
|
||||
|
||||
/** The composer builds a new list on every keystroke and refetch, often of the results it had. */
|
||||
function isSameResultSet(
|
||||
shown: readonly AutocompleteOption[],
|
||||
arrived: readonly AutocompleteOption[],
|
||||
): boolean {
|
||||
return (
|
||||
shown.length === arrived.length && shown.every((option, row) => option.id === arrived[row].id)
|
||||
);
|
||||
}
|
||||
|
||||
/** The end of the list the selection sits at, if it sits at one. The best match is last. */
|
||||
function endSelected(
|
||||
options: readonly AutocompleteOption[],
|
||||
selectedIndex: number,
|
||||
): ListEnd | null {
|
||||
if (selectedIndex === options.length - 1) return "composer";
|
||||
if (selectedIndex === 0) return "far";
|
||||
return null;
|
||||
}
|
||||
|
||||
const FULLY_VISIBLE = { itemVisiblePercentThreshold: 100 };
|
||||
|
||||
function ListPadding() {
|
||||
return <View style={styles.listPadding} />;
|
||||
}
|
||||
|
||||
function keyOfOption(option: AutocompleteOption): string {
|
||||
return option.id;
|
||||
}
|
||||
|
||||
interface AutocompleteRowsProps {
|
||||
/** Nearest the resting end first. */
|
||||
rows: readonly AutocompleteOption[];
|
||||
selectedRow: number;
|
||||
restsAt: ListEnd;
|
||||
maxHeight: number;
|
||||
onSelect: (option: AutocompleteOption) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows, read from the end the list rests at. Against the composer the list is inverted. Either
|
||||
* way its resting scroll position is that end and the rows nearest it mount first, so a list of any
|
||||
* length opens where it rests.
|
||||
*/
|
||||
function AutocompleteRows({
|
||||
rows,
|
||||
selectedRow,
|
||||
restsAt,
|
||||
maxHeight,
|
||||
onSelect,
|
||||
}: AutocompleteRowsProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const listRef = useRef<FlatList<AutocompleteOption>>(null);
|
||||
const fullyVisibleRows = useRef<{ first: number; last: number } | null>(null);
|
||||
|
||||
// The row the keyboard selected, until it has been brought into the viewport. The row at the
|
||||
// other end is never revealed here: selecting it gives the list a scroller that rests there.
|
||||
const pendingReveal = useRef<number | null>(null);
|
||||
const isPendingRowMeasured = useRef(true);
|
||||
const revealPendingRow = useCallback(() => {
|
||||
const list = listRef.current;
|
||||
const row = pendingReveal.current;
|
||||
const visible = fullyVisibleRows.current;
|
||||
if (!list || row === null || !visible) return;
|
||||
const span = visible.last - visible.first;
|
||||
if (row >= visible.first && row <= visible.last) {
|
||||
pendingReveal.current = null;
|
||||
} else if (row === 0) {
|
||||
list.scrollToOffset({ offset: 0, animated: false });
|
||||
pendingReveal.current = null;
|
||||
fullyVisibleRows.current = { first: 0, last: span };
|
||||
} else {
|
||||
// The row comes in at the edge it was beyond.
|
||||
const isNearer = row < visible.first;
|
||||
isPendingRowMeasured.current = true;
|
||||
list.scrollToIndex({ index: row, viewPosition: isNearer ? 0 : 1, animated: false });
|
||||
// A row that has not mounted has no position yet: the list was sent towards it instead, and
|
||||
// is asked again as rows mount.
|
||||
if (!isPendingRowMeasured.current) return;
|
||||
pendingReveal.current = null;
|
||||
// The list reports what it shows after it has scrolled; the next key press may come first.
|
||||
fullyVisibleRows.current = isNearer
|
||||
? { first: row, last: row + span }
|
||||
: { first: row - span, last: row };
|
||||
}
|
||||
}, []);
|
||||
const handleScrollToIndexFailed = useCallback(
|
||||
(info: { index: number; averageItemLength: number }) => {
|
||||
isPendingRowMeasured.current = false;
|
||||
listRef.current?.scrollToOffset({
|
||||
offset: info.index * info.averageItemLength,
|
||||
animated: false,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
pendingReveal.current = selectedRow < 0 ? null : selectedRow;
|
||||
revealPendingRow();
|
||||
}, [revealPendingRow, selectedRow]);
|
||||
|
||||
const handleViewableItemsChanged = useCallback(
|
||||
({ viewableItems }: { viewableItems: ViewToken<AutocompleteOption>[] }) => {
|
||||
const indexes = viewableItems.flatMap((token) => (token.index === null ? [] : [token.index]));
|
||||
if (indexes.length === 0) return;
|
||||
fullyVisibleRows.current = { first: Math.min(...indexes), last: Math.max(...indexes) };
|
||||
revealPendingRow();
|
||||
},
|
||||
[revealPendingRow],
|
||||
);
|
||||
|
||||
// A viewport that got shorter can leave the selected row outside it.
|
||||
const handleLayout = useCallback(() => {
|
||||
// The list reports the rows its new height shows before the row is checked against them.
|
||||
listRef.current?.recordInteraction();
|
||||
pendingReveal.current = selectedRow < 0 ? null : selectedRow;
|
||||
revealPendingRow();
|
||||
}, [revealPendingRow, selectedRow]);
|
||||
|
||||
const renderRow = useCallback(
|
||||
({ item, index }: ListRenderItemInfo<AutocompleteOption>) => (
|
||||
<AutocompleteRow
|
||||
option={item}
|
||||
isSelected={index === selectedRow}
|
||||
showArgumentHint={!isCompact}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
),
|
||||
[isCompact, onSelect, selectedRow],
|
||||
);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
inverted={restsAt === "composer"}
|
||||
data={rows}
|
||||
renderItem={renderRow}
|
||||
keyExtractor={keyOfOption}
|
||||
// The first commit fills the tallest popover, so the popover never opens short and grows.
|
||||
initialNumToRender={Math.ceil(maxHeight / ROW_MIN_HEIGHT) + 1}
|
||||
removeClippedSubviews={false}
|
||||
viewabilityConfig={FULLY_VISIBLE}
|
||||
onViewableItemsChanged={handleViewableItemsChanged}
|
||||
onLayout={handleLayout}
|
||||
onContentSizeChange={revealPendingRow}
|
||||
onScrollToIndexFailed={handleScrollToIndexFailed}
|
||||
// The list's padding. As a header and footer it counts in the list's own scroll positions.
|
||||
ListHeaderComponent={ListPadding}
|
||||
ListFooterComponent={ListPadding}
|
||||
style={styles.scrollView}
|
||||
keyboardShouldPersistTaps="always"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Autocomplete({
|
||||
options,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
status,
|
||||
maxHeight = 220,
|
||||
maxHeight = DEFAULT_MAX_HEIGHT,
|
||||
}: AutocompleteProps) {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map());
|
||||
const viewportHeightRef = useRef(0);
|
||||
const scrollOffsetRef = useRef(0);
|
||||
|
||||
const ensureActiveItemVisible = useCallback(() => {
|
||||
if (selectedIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const layout = rowLayoutsRef.current.get(selectedIndex);
|
||||
if (!layout) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextOffset = getScrollOffsetToRevealItem({
|
||||
currentOffset: scrollOffsetRef.current,
|
||||
viewportHeight: viewportHeightRef.current,
|
||||
itemTop: layout.top,
|
||||
itemHeight: layout.height,
|
||||
});
|
||||
|
||||
if (Math.abs(nextOffset - scrollOffsetRef.current) < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollOffsetRef.current = nextOffset;
|
||||
scrollRef.current?.scrollTo({ y: nextOffset, animated: false });
|
||||
}, [selectedIndex]);
|
||||
|
||||
const pinToBottom = useCallback(() => {
|
||||
scrollRef.current?.scrollToEnd({ animated: false });
|
||||
requestAnimationFrame(() => {
|
||||
scrollRef.current?.scrollToEnd({ animated: false });
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
rowLayoutsRef.current.clear();
|
||||
scrollOffsetRef.current = 0;
|
||||
}, [options]);
|
||||
|
||||
useEffect(() => {
|
||||
if (options.length === 0) {
|
||||
return;
|
||||
}
|
||||
pinToBottom();
|
||||
}, [options, pinToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const raf = requestAnimationFrame(ensureActiveItemVisible);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [ensureActiveItemVisible, options.length]);
|
||||
|
||||
const handleScrollViewLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
viewportHeightRef.current = event.nativeEvent.layout.height;
|
||||
ensureActiveItemVisible();
|
||||
},
|
||||
[ensureActiveItemVisible],
|
||||
const scroller = useScroller(options, selectedIndex);
|
||||
const rows = useMemo(
|
||||
() => (scroller.restsAt === "composer" ? options.toReversed() : options),
|
||||
[options, scroller.restsAt],
|
||||
);
|
||||
|
||||
const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
||||
}, []);
|
||||
|
||||
const handleRowLayout = useCallback(
|
||||
(index: number, event: LayoutChangeEvent) => {
|
||||
rowLayoutsRef.current.set(index, {
|
||||
top: event.nativeEvent.layout.y,
|
||||
height: event.nativeEvent.layout.height,
|
||||
});
|
||||
ensureActiveItemVisible();
|
||||
},
|
||||
[ensureActiveItemVisible],
|
||||
);
|
||||
|
||||
const selectedRow =
|
||||
scroller.restsAt === "composer" && selectedIndex >= 0
|
||||
? options.length - 1 - selectedIndex
|
||||
: selectedIndex;
|
||||
const containerStyle = useMemo(() => [styles.container, { maxHeight }], [maxHeight]);
|
||||
|
||||
const noResultsMessage = t("common.empty.noResults");
|
||||
@@ -308,28 +417,14 @@ export function Autocomplete({
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
onLayout={handleScrollViewLayout}
|
||||
onContentSizeChange={pinToBottom}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="always"
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<AutocompleteRow
|
||||
key={option.id}
|
||||
index={index}
|
||||
option={option}
|
||||
isSelected={index === selectedIndex}
|
||||
showArgumentHint={!isCompact}
|
||||
onSelect={onSelect}
|
||||
onRowLayout={handleRowLayout}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
<AutocompleteRows
|
||||
key={scroller.key}
|
||||
rows={rows}
|
||||
selectedRow={selectedRow}
|
||||
restsAt={scroller.restsAt}
|
||||
maxHeight={maxHeight}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -350,14 +445,14 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingVertical: theme.spacing[1],
|
||||
listPadding: {
|
||||
height: theme.spacing[1],
|
||||
},
|
||||
item: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minHeight: 36,
|
||||
minHeight: ROW_MIN_HEIGHT,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
|
||||
@@ -45,7 +45,6 @@ export interface RevealActiveItemHandlers {
|
||||
* content. Measuring on selection/list changes also covers retained rows that
|
||||
* move or keep their size without emitting another onLayout event on web.
|
||||
* A list replacement resets to the top only when selection restarts there.
|
||||
* Bottom-anchored lists share only getScrollOffsetToRevealItem.
|
||||
*/
|
||||
export function useRevealActiveItem(options: {
|
||||
activeIndex: number;
|
||||
|
||||
@@ -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