Keep Add Project keyboard selection visible (#7081)

* fix(app): scroll the Add Project results to the keyboard-selected row

The results list had no scroll ref, so ArrowUp/ArrowDown moved the selection off-screen; the autocomplete reveal math is now shared through useRevealActiveItem.

* test(app): verify the directory selected beyond the results viewport

Check scrolling in both directions and across wrapping, then confirm Enter opens the selected directory. The previous regression used an aria-selected attribute that result buttons do not render.

Co-authored-by: superheher <heh@vivaldi.net>

* test(app): verify scroll reveal through browser behavior

Keep pure offset checks and cover keyboard navigation, wrapping, and replacing search results in the real Add Project flow.

* fix(app): measure retained Add Project rows when revealing selection

* fix(app): measure scroll content through a native View reference

---------

Co-authored-by: superheher <heh@vivaldi.net>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-16 07:04:35 +02:00
committed by GitHub
co-authored by superheher
parent 5c2a1552df
commit 8388f16186
8 changed files with 372 additions and 101 deletions
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { mkdir, mkdtemp, rm, stat } from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import path from "node:path";
import { test, expect } from "../support/fixtures";
import {
@@ -12,12 +12,15 @@ import {
chooseAddProjectMethod,
expectAddProjectPage,
expectNewWorkspaceForAddedProject,
expectAddProjectResultVisible,
navigateAddProjectResults,
openAddProjectFlow,
openAddProjectHostSelection,
} from "../support/helpers/add-project-flow";
import { gotoAppShell } from "../support/helpers/app";
import {
addConnectedHostAndReload,
addConnectedHostsAndReload,
addOfflineHostAndReload,
waitForConnectedHost,
} from "../support/helpers/hosts";
@@ -214,6 +217,60 @@ test.describe("Add Project command-center flow", () => {
});
});
test("filtered hosts keep the keyboard-selected row visible", async ({ page }) => {
const hosts: IsolatedHostDaemon[] = [];
try {
for (let index = 1; index <= 3; index += 1) {
hosts.push(await startIsolatedHostDaemon(`scroll-host-${index}`));
}
await page.setViewportSize({ width: 1280, height: 360 });
await gotoAppShell(page);
await addConnectedHostsAndReload(
page,
hosts.map((host, index) => ({
serverId: host.serverId,
port: host.port,
label: index < 2 ? `Fleet 0${index + 1}` : "Other host",
})),
{ primaryLabel: "Fleet 00" },
);
for (const host of hosts) {
await waitForConnectedHost(page, {
serverId: host.serverId,
endpoint: `localhost:${host.port}`,
});
}
await openAddProjectHostSelection(page);
await expect(
addProjectFlow(page).getByRole("button").filter({ hasText: "Other host" }),
).toBeVisible();
await addProjectFlowInput(page).fill("Fleet");
await expect(addProjectFlowHost(page, hosts[2]!.serverId)).toHaveCount(0);
await navigateAddProjectResults(page, "ArrowDown", 2);
await expectAddProjectResultVisible(page, "Fleet 02");
// Filtering out the first host moves retained rows to new positions.
await addProjectFlowInput(page).fill("scroll-host");
await expect(addProjectFlowHost(page, getServerId())).toHaveCount(0);
await expectAddProjectResultVisible(page, "Fleet 01");
await navigateAddProjectResults(page, "ArrowDown", 2);
await expectAddProjectResultVisible(page, "Other host");
await navigateAddProjectResults(page, "ArrowDown", 1);
await expectAddProjectResultVisible(page, "Fleet 01");
await addProjectFlowInput(page).fill("Fleet");
await expectAddProjectResultVisible(page, "Fleet 00");
await navigateAddProjectResults(page, "ArrowDown", 2);
await expectAddProjectResultVisible(page, "Fleet 02");
await page.keyboard.press("Enter");
await expectAddProjectPage(page, "method");
await expect(addProjectFlow(page)).toContainText("Fleet 02");
} finally {
await Promise.all(hosts.map((host) => host.close()));
}
});
test("keyboard directory search adds the selected Project", async ({
page,
projectPickerFixture,
@@ -241,6 +298,59 @@ test.describe("Add Project command-center flow", () => {
await expectProjectHasNoWorkspaces(projectId);
});
test("keyboard navigation keeps the selected row inside the visible results", async ({
page,
}) => {
const parentDirectory = await mkdtemp(path.join(homedir(), "paseo-e2e-scroll-"));
const rowPath = (index: number) =>
path.join(parentDirectory, `scroll-probe-${String(index).padStart(2, "0")}`);
let projectId: string | null = null;
try {
await Promise.all(Array.from({ length: 16 }, (_, index) => mkdir(rowPath(index))));
await gotoAppShell(page);
await openAddProjectFlow(page);
await chooseAddProjectMethod(page, "directory-search");
await addProjectFlowInput(page).fill(path.basename(parentDirectory));
const results = page.getByTestId("add-project-flow-results");
await expect(results.getByRole("button", { name: /scroll-probe-/ })).toHaveCount(16, {
timeout: 30_000,
});
await navigateAddProjectResults(page, "ArrowDown", 12);
await expectAddProjectResultVisible(page, rowPath(11));
await navigateAddProjectResults(page, "ArrowUp", 12);
await expectAddProjectResultVisible(page, parentDirectory);
await navigateAddProjectResults(page, "ArrowUp", 1);
await expectAddProjectResultVisible(page, rowPath(15));
await navigateAddProjectResults(page, "ArrowDown", 1);
await expectAddProjectResultVisible(page, parentDirectory);
await navigateAddProjectResults(page, "ArrowDown", 12);
await expectAddProjectResultVisible(page, rowPath(11));
await addProjectFlowInput(page).fill(`${path.basename(parentDirectory)}probe`);
await expect(results.getByRole("button")).toHaveCount(16);
await expectAddProjectResultVisible(page, rowPath(0));
await navigateAddProjectResults(page, "ArrowDown", 12);
await expectAddProjectResultVisible(page, rowPath(12));
await addProjectFlowInput(page).fill(path.basename(parentDirectory));
await expect(results.getByRole("button")).toHaveCount(17);
await expectAddProjectResultVisible(page, parentDirectory);
await navigateAddProjectResults(page, "ArrowDown", 12);
await expectAddProjectResultVisible(page, rowPath(11));
await page.keyboard.press("Enter");
projectId = await expectOpenedProject(page, "scroll-probe-11");
await expectNewWorkspaceForAddedProject(page, {
serverId: getServerId(),
projectId,
projectName: "scroll-probe-11",
projectPath: rowPath(11),
});
} finally {
if (projectId) await removeCreatedProject(rowPath(11), projectId);
await rm(parentDirectory, { recursive: true, force: true });
}
});
test("a complete repository URL remains selectable without a GitHub search result", async ({
page,
}) => {
@@ -90,3 +90,26 @@ export async function expectNewWorkspaceForAddedProject(
{ timeout: 30_000 },
);
}
export async function navigateAddProjectResults(
page: Page,
key: "ArrowDown" | "ArrowUp",
count: number,
): Promise<void> {
for (let index = 0; index < count; index += 1) await page.keyboard.press(key);
}
export async function expectAddProjectResultVisible(page: Page, text: string): Promise<void> {
const results = page.getByTestId("add-project-flow-results");
const row = results.getByRole("button").filter({ has: page.getByText(text, { exact: true }) });
await expect
.poll(async () => {
const viewport = await results.boundingBox();
const bounds = await row.boundingBox();
if (!viewport || !bounds) return false;
return (
bounds.y >= viewport.y - 1 && bounds.y + bounds.height <= viewport.y + viewport.height + 1
);
})
.toBe(true);
}
@@ -19,6 +19,7 @@ import {
useRef,
useState,
type ComponentType,
type RefObject,
} from "react";
import {
Modal,
@@ -71,6 +72,7 @@ import {
type ProjectPickerOption,
} from "@/components/project-picker-options";
import { Shortcut } from "@/components/ui/shortcut";
import { useRevealActiveItem } from "@/components/ui/scroll-reveal";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { getIsElectronRuntime } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
@@ -252,7 +254,17 @@ function pathTestId(path: string): string {
return `add-project-flow-path-${encodeURIComponent(path)}`;
}
function FlowRow({ option, active }: { option: FlowRowOption; active: boolean }) {
function FlowRow({
option,
active,
activeItemRef,
onRowLayout,
}: {
option: FlowRowOption;
active: boolean;
activeItemRef: RefObject<View | null>;
onRowLayout: () => void;
}) {
const accessibilityState = useMemo(
() => ({ disabled: option.disabled === true, selected: active }),
[active, option.disabled],
@@ -270,6 +282,8 @@ function FlowRow({ option, active }: { option: FlowRowOption; active: boolean })
accessibilityRole="button"
accessibilityState={accessibilityState}
disabled={option.disabled}
ref={active ? activeItemRef : undefined}
onLayout={active ? onRowLayout : undefined}
onPress={option.select}
style={rowStyle}
testID={option.testID}
@@ -384,6 +398,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
);
const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces);
const inputRef = useRef<EditingTextInputHandle>(null);
const resultsRef = useRef<ScrollView>(null);
const submissionInFlightRef = useRef(false);
const browseInFlightRef = useRef(false);
const query = page.kind === "new-directory-name" || page.kind === "method" ? "" : page.query;
@@ -715,6 +730,13 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
]);
const activeIndex = rows.length === 0 ? 0 : Math.min(page.activeIndex, rows.length - 1);
const rowListKey = useMemo(() => rows.map((row) => row.id).join("\u0000"), [rows]);
const { activeItemRef, contentRef, onItemLayout, onViewportLayout, onScroll } =
useRevealActiveItem({
activeIndex,
listKey: rowListKey,
scrollRef: resultsRef,
});
const createDirectory = useCallback(async () => {
if (page.kind !== "new-directory-name" || !client) return;
const name = page.name.trim();
@@ -905,53 +927,64 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
) : null}
</View>
<ScrollView
ref={resultsRef}
style={styles.results}
contentContainerStyle={styles.resultsContent}
keyboardShouldPersistTaps="always"
onLayout={onViewportLayout}
onScroll={onScroll}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
testID="add-project-flow-results"
>
{preview ? (
<Text style={styles.preview} testID="add-project-flow-path-preview">
{shortenPath(preview)}
</Text>
) : null}
{isSubmitting ? (
<Text style={styles.stateText} testID="add-project-flow-progress">
{progressText(page)}
</Text>
) : null}
{!isSubmitting && page.error ? (
<Text style={styles.errorText} testID="add-project-flow-error">
{page.error}
</Text>
) : null}
{!isSubmitting && queryError ? (
<Text style={styles.errorText} testID="add-project-flow-query-error">
{queryError}
</Text>
) : null}
{!isSubmitting && loading ? (
<Text style={styles.stateText} testID="add-project-flow-loading">
Loading...
</Text>
) : null}
{!isSubmitting &&
(!loading || page.kind === "github-search") &&
(!queryError || page.kind === "github-search")
? rows.map((option, index) => (
<FlowRow key={option.id} option={option} active={index === activeIndex} />
))
: null}
{!isSubmitting &&
!loading &&
!queryError &&
rows.length === 0 &&
page.kind !== "new-directory-name" ? (
<Text style={styles.stateText} testID="add-project-flow-empty">
{emptyText(page, host ?? null)}
</Text>
) : null}
<View ref={contentRef} collapsable={false} style={styles.resultsContent}>
{preview ? (
<Text style={styles.preview} testID="add-project-flow-path-preview">
{shortenPath(preview)}
</Text>
) : null}
{isSubmitting ? (
<Text style={styles.stateText} testID="add-project-flow-progress">
{progressText(page)}
</Text>
) : null}
{!isSubmitting && page.error ? (
<Text style={styles.errorText} testID="add-project-flow-error">
{page.error}
</Text>
) : null}
{!isSubmitting && queryError ? (
<Text style={styles.errorText} testID="add-project-flow-query-error">
{queryError}
</Text>
) : null}
{!isSubmitting && loading ? (
<Text style={styles.stateText} testID="add-project-flow-loading">
Loading...
</Text>
) : null}
{!isSubmitting &&
(!loading || page.kind === "github-search") &&
(!queryError || page.kind === "github-search")
? rows.map((option, index) => (
<FlowRow
key={option.id}
option={option}
activeItemRef={activeItemRef}
active={index === activeIndex}
onRowLayout={onItemLayout}
/>
))
: null}
{!isSubmitting &&
!loading &&
!queryError &&
rows.length === 0 &&
page.kind !== "new-directory-name" ? (
<Text style={styles.stateText} testID="add-project-flow-empty">
{emptyText(page, host ?? null)}
</Text>
) : null}
</View>
</ScrollView>
<View style={styles.footer} testID="add-project-flow-footer">
<FlowHint keys={NAVIGATION_HINT_KEYS} action="Navigate" />
@@ -1,10 +1,6 @@
import { describe, expect, it } from "vitest";
import {
getAutocompleteFallbackIndex,
getAutocompleteScrollOffset,
orderAutocompleteOptions,
} from "./autocomplete-utils";
import { getAutocompleteFallbackIndex, orderAutocompleteOptions } from "./autocomplete-utils";
const OPTIONS = ["alpha", "beta", "gamma"];
@@ -28,27 +24,3 @@ describe("getAutocompleteFallbackIndex", () => {
expect(getAutocompleteFallbackIndex(3, "below-input")).toBe(0);
});
});
describe("getAutocompleteScrollOffset", () => {
it("scrolls up when the active item is above the viewport", () => {
expect(
getAutocompleteScrollOffset({
currentOffset: 120,
viewportHeight: 80,
itemTop: 90,
itemHeight: 20,
}),
).toBe(90);
});
it("scrolls down when the active item is below the viewport", () => {
expect(
getAutocompleteScrollOffset({
currentOffset: 0,
viewportHeight: 100,
itemTop: 150,
itemHeight: 24,
}),
).toBe(74);
});
});
@@ -29,28 +29,3 @@ export function getAutocompleteNextIndex(args: {
}): number {
return getNextActiveIndex(args);
}
export function getAutocompleteScrollOffset(args: {
currentOffset: number;
viewportHeight: number;
itemTop: number;
itemHeight: number;
}): number {
if (args.viewportHeight <= 0) {
return args.currentOffset;
}
const itemBottom = args.itemTop + args.itemHeight;
const viewportTop = args.currentOffset;
const viewportBottom = args.currentOffset + args.viewportHeight;
if (args.itemTop < viewportTop) {
return Math.max(0, args.itemTop);
}
if (itemBottom > viewportBottom) {
return Math.max(0, itemBottom - args.viewportHeight);
}
return args.currentOffset;
}
@@ -13,7 +13,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { File, Folder } from "lucide-react-native";
import type { Theme } from "@/styles/theme";
import { getAutocompleteScrollOffset } from "./autocomplete-utils";
import { getScrollOffsetToRevealItem } from "./scroll-reveal";
export interface AutocompleteOption {
id: string;
@@ -146,7 +146,7 @@ export function Autocomplete({
return;
}
const nextOffset = getAutocompleteScrollOffset({
const nextOffset = getScrollOffsetToRevealItem({
currentOffset: scrollOffsetRef.current,
viewportHeight: viewportHeightRef.current,
itemTop: layout.top,
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { getScrollOffsetToRevealItem } from "./scroll-reveal";
describe("getScrollOffsetToRevealItem", () => {
it("keeps the offset when the item is already inside the viewport", () => {
expect(
getScrollOffsetToRevealItem({
currentOffset: 40,
viewportHeight: 200,
itemTop: 100,
itemHeight: 40,
}),
).toBe(40);
});
it("scrolls up to the item top when the item is above the viewport", () => {
expect(
getScrollOffsetToRevealItem({
currentOffset: 300,
viewportHeight: 200,
itemTop: 240,
itemHeight: 40,
}),
).toBe(240);
});
it("scrolls down just far enough to reveal the item bottom", () => {
expect(
getScrollOffsetToRevealItem({
currentOffset: 0,
viewportHeight: 200,
itemTop: 380,
itemHeight: 40,
}),
).toBe(220);
});
it("stays put before the viewport has been measured", () => {
expect(
getScrollOffsetToRevealItem({
currentOffset: 120,
viewportHeight: 0,
itemTop: 400,
itemHeight: 40,
}),
).toBe(120);
});
});
@@ -0,0 +1,110 @@
import { useCallback, useLayoutEffect, useRef, type RefObject } from "react";
import type {
LayoutChangeEvent,
NativeScrollEvent,
NativeSyntheticEvent,
ScrollView,
View,
} from "react-native";
export function getScrollOffsetToRevealItem(args: {
currentOffset: number;
viewportHeight: number;
itemTop: number;
itemHeight: number;
}): number {
if (args.viewportHeight <= 0) {
return args.currentOffset;
}
const itemBottom = args.itemTop + args.itemHeight;
const viewportTop = args.currentOffset;
const viewportBottom = args.currentOffset + args.viewportHeight;
if (args.itemTop < viewportTop) {
return Math.max(0, args.itemTop);
}
if (itemBottom > viewportBottom) {
return Math.max(0, itemBottom - args.viewportHeight);
}
return args.currentOffset;
}
export interface RevealActiveItemHandlers {
activeItemRef: RefObject<View | null>;
contentRef: RefObject<View | null>;
onItemLayout: () => void;
onViewportLayout: (event: LayoutChangeEvent) => void;
onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
}
/**
* Reveals the keyboard-selected row using its current position in the scroll
* 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;
listKey: string;
scrollRef: RefObject<ScrollView | null>;
}): RevealActiveItemHandlers {
const { activeIndex, listKey, scrollRef } = options;
const activeItemRef = useRef<View | null>(null);
const contentRef = useRef<View | null>(null);
const viewportHeightRef = useRef(0);
const offsetRef = useRef(0);
const measurementRef = useRef(0);
const reveal = useCallback(() => {
const request = ++measurementRef.current;
const item = activeItemRef.current;
const scroll = scrollRef.current;
const content = contentRef.current;
if (!item || !scroll || !content) return;
item.measureLayout(content, (_left, top, _width, height) => {
if (request !== measurementRef.current || activeItemRef.current !== item) return;
const nextOffset = getScrollOffsetToRevealItem({
currentOffset: offsetRef.current,
viewportHeight: viewportHeightRef.current,
itemTop: top,
itemHeight: height,
});
if (Math.abs(nextOffset - offsetRef.current) < 1) return;
offsetRef.current = nextOffset;
scroll.scrollTo({ y: nextOffset, animated: false });
});
}, [scrollRef]);
const prevListKeyRef = useRef(listKey);
useLayoutEffect(() => {
if (prevListKeyRef.current !== listKey && activeIndex === 0) {
offsetRef.current = 0;
scrollRef.current?.scrollTo({ y: 0, animated: false });
}
prevListKeyRef.current = listKey;
reveal();
return () => {
// Measurements are asynchronous; a previous selection must not scroll us.
measurementRef.current += 1;
};
}, [activeIndex, listKey, reveal, scrollRef]);
const onViewportLayout = useCallback(
(event: LayoutChangeEvent) => {
viewportHeightRef.current = event.nativeEvent.layout.height;
reveal();
},
[reveal],
);
const onScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
offsetRef.current = event.nativeEvent.contentOffset.y;
}, []);
return { activeItemRef, contentRef, onItemLayout: reveal, onViewportLayout, onScroll };
}