Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 31b22b4497 fix(app): keep the autocomplete list when the same results arrive again
The composer hands the popover a new list on every keystroke and refetch, often
holding the results it already showed. The list started over for each one: a
second mount before the filtered results, and a list the user had scrolled
returned to its rest. A result set is now its options in order.
2026-11-14 17:04:54 +01:00
Mohamed Boudra dafc9e470b fix(app): keep the selected autocomplete row in view when the popover gets shorter
The row the arrow keys selected was brought into view once. A popover that then
lost height, because the composer grew or the window shrank, cut that row off
while Enter still chose it. The list checks the selected row again whenever its
height changes.

The test that counted scroll calls through patched Element prototypes now reads
the list's resting scroll position instead.
2026-11-14 16:53:25 +01:00
Mohamed Boudra 5d5f88a5cf fix(app): rest the autocomplete list at the far end when the selection wraps there
Wrapping the arrow-key selection from the best match to the far end of a long
list scrolled there over several frames, because the rows in between were not
mounted. The list now gets a scroller that rests at the end the selection
wrapped to, so the jump is one frame and mounts only the rows it shows.
2026-11-14 16:49:53 +01:00
Mohamed Boudra 2235d3513f fix(app): open the autocomplete list where it rests, with only the rows it shows
The list mounted every result and then scrolled itself to the composer edge.
On Android the scroll command reaches the screen one or two frames after the
rows, so each open and each result change painted the far end of the list, or
the selection 8 px low, before the resting frame. Mounting 115 commands also
held the popover's first frame for 500 ms to 1 s.

The list is now an inverted, virtualized list: its resting scroll position is
the composer edge, and the first commit holds the rows that fill the popover.
Each result set gets its own list, so a result change starts at the composer
edge with the same bounded commit. Arrow-key selection is revealed through the
list's own viewability and scroll-to-index.
2026-11-14 16:37:26 +01:00
Mohamed Boudra f5499fd336 fix(app): close the autocomplete popover while its retained panel is hidden
A workspace stays mounted with display: none while another route shows, and the popover stayed open inside it. On native its frame then failed to measure the hidden composer, stopped tracking, and kept the stale placement after the user came back. The popover now closes with its panel and reopens with a fresh measurement.
2026-11-14 11:07:48 +01:00
Mohamed Boudra a9bc26bed2 fix(app): stop the native popover frame measuring a composer that left the tree
When the composer unmounts with the popover open, the portal removes the frame a commit later. A busy JS thread holds that commit for many frames, and every failed measure() logged a Reanimated warning: 31 to 111 per layout swap on Android. A gate written from the anchor's ref callback arrives on the UI thread only after that same JS task, so the frame now stops on its first failed measure and falls back to the rest measurement.
2026-11-14 10:51:28 +01:00
Mohamed Boudra 3503edc65b docs(app): state where the native popover's fixed-bottom tracking stops holding
The native frame follows only the composer's height so it never depends on whether iOS's native-driver keyboard lift has reached the shadow tree. The centered New workspace composer and width changes still wait for the rest measurement, as before.
2026-11-14 10:23:04 +01:00
Mohamed Boudra 9dedc14bbb fix(app): keep the command popover against the composer as it resizes
The popover lives in a portal and positioned itself from an asynchronous
copy of the composer's rect. Any composer layout change painted first: a
wrapped line overlapped the popover, cleared text left it floating above
the composer, and a window resize left it at the old width, for one frame
on web and up to seconds on Android under JS load.

The popover now follows the composer's live size between measurements.
Native reads the anchor's height on the UI thread every frame with a
Reanimated frame callback; web commits the placement from a
ResizeObserver with flushSync, before paint. The keyboard translate view
in the portal becomes host-sized and box-none, so the popover moves
inside it without leaving its parent's bounds (Android hit-testing).
2026-11-14 10:04:54 +01:00
Mohamed Boudra 11c28a48ea fix(app): open the command popover in place on native
Reanimated computes a style's first value when its hook first runs and sends
later values from an effect after the mounting commit, which native paints.
The popover's height bound lived in the component that renders while the
popover is closed, so it started at 0. Every open painted one frame with the
popover at its minimum height, hanging down over the composer, before the
measured bound arrived. The bound now lives in a frame that mounts only with
a measured anchor.

Browser tests could not apply animated style updates at all: Reanimated loads
its react-native-web style helpers with require, which Vite's ESM serving
lacks, so the first update after mount crashed the component.
2026-11-14 09:19:32 +01:00
10 changed files with 1067 additions and 187 deletions
+30 -6
View File
@@ -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);
});
@@ -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,
),
};
}
+217 -122
View File
@@ -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;
+23
View File
@@ -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",