Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/mobile/src/components/modal-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export function focusAccessibility(target: Text | View | null | undefined) {
if (handle) AccessibilityInfo.setAccessibilityFocus(handle);
}

function useReturnFocusAfterClose(
export function useReturnFocusAfterClose(
visible: boolean,
returnFocusRef: RefObject<View | null> | undefined,
) {
Expand Down
122 changes: 122 additions & 0 deletions apps/mobile/src/components/popover-card.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { expect, jest, test } from "@jest/globals";
import { act, fireEvent, render, screen } from "@testing-library/react-native";
import type { RefObject } from "react";
import { Dimensions, Keyboard, Modal, StyleSheet, Text, type View } from "react-native";

import { space } from "../theme";
import { PopoverCard } from "./popover-card";

const mockInsets = { bottom: 0, left: 0, right: 0, top: 0 };
jest.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => mockInsets }));

function anchorAt(position: { y: number }, height = 36) {
return {
current: {
measureInWindow: (callback: (x: number, y: number, w: number, h: number) => void) =>
callback(16, position.y, 120, height),
},
} as unknown as RefObject<View | null>;
}

function renderCard(
anchorRef: RefObject<View | null>,
props: Partial<Parameters<typeof PopoverCard>[0]> = {},
) {
const onClose = jest.fn();
render(
<PopoverCard
anchorRef={anchorRef}
label="Background work"
onClose={onClose}
returnFocusRef={{ current: null }}
visible
{...props}
>
<Text>Row</Text>
</PopoverCard>,
);
return { onClose };
}

const card = () => screen.getByTestId("popover-card");

test("pins the card's bottom edge to the bottom of the control it opens from", () => {
const windowHeight = Dimensions.get("window").height;
const { onClose } = renderCard(anchorAt({ y: windowHeight - 200 }));
expect(card()).toHaveStyle({ bottom: 164 });
fireEvent.press(screen.getByTestId("popover-backdrop", { includeHiddenElements: true }));
expect(onClose).toHaveBeenCalledTimes(1);
});

test("never grows taller than the space above it, and fills it for a self-sizing page", () => {
const windowHeight = Dimensions.get("window").height;
// An anchor near the top leaves little room: the card shrinks instead of a fixed minimum.
renderCard(anchorAt({ y: 60 }), { fill: true, scrollable: false });
const style = StyleSheet.flatten(card().props.style);
expect(style.bottom).toBe(windowHeight - 96);
expect(style.maxHeight).toBeLessThan(160);
expect(style.height).toBe(style.maxHeight);
});

test("measures the control again when the window changes size or the keyboard moves", () => {
const windowHeight = Dimensions.get("window").height;
const position = { y: windowHeight - 200 };
const listeners = new Map<string, () => void>();
const addListener = jest.spyOn(Keyboard, "addListener").mockImplementation(((
event: string,
listener: () => void,
) => {
listeners.set(event, listener);
return { remove: jest.fn() };
}) as never);
const window = Dimensions.get("window");
try {
renderCard(anchorAt(position));
expect(card()).toHaveStyle({ bottom: 164 });
position.y = windowHeight - 300;
act(() => {
Dimensions.set({ window: { ...window, width: window.width + 1 } });
});
expect(card()).toHaveStyle({ bottom: 264 });
position.y = windowHeight - 250;
act(() => listeners.get("keyboardDidHide")?.());
expect(card()).toHaveStyle({ bottom: 214 });
// The card can fold the composer under its anchor as it opens; showing measures again.
position.y = windowHeight - 220;
act(() => screen.UNSAFE_getByType(Modal).props.onShow());
expect(card()).toHaveStyle({ bottom: 184 });
} finally {
act(() => {
Dimensions.set({ window });
});
addListener.mockRestore();
}
});

test("the escape gesture uses the inner page's back before closing", () => {
const onRequestClose = jest.fn();
const { onClose } = renderCard(anchorAt({ y: 600 }), { onRequestClose });
fireEvent(card(), "accessibilityEscape");
expect(onRequestClose).toHaveBeenCalledTimes(1);
expect(onClose).not.toHaveBeenCalled();
});

test("stays clear of a notch on one side instead of centering into it", () => {
const window = Dimensions.get("window");
mockInsets.right = 44;
try {
act(() => {
Dimensions.set({ window: { ...window, width: 700 } });
});
renderCard(anchorAt({ y: 300 }));
const style = StyleSheet.flatten(card().props.style);
const rightMargin = 700 - Number(style.left) - Number(style.width);
expect(rightMargin).toBeGreaterThanOrEqual(44 + space.sm);
expect(Number(style.left)).toBeGreaterThanOrEqual(space.md);
} finally {
mockInsets.right = 0;
act(() => {
Dimensions.set({ window });
});
}
});
176 changes: 176 additions & 0 deletions apps/mobile/src/components/popover-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { type ReactNode, type RefObject, useEffect, useRef, useState } from "react";
import {
Keyboard,
Modal,
Pressable,
ScrollView,
StyleSheet,
type Text,
useWindowDimensions,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { floatingShadow, palette, radius, space } from "../theme";
import { focusAccessibility, useReturnFocusAfterClose } from "./modal-sheet";

// Matches the sheet's readable width on tablets.
const maxCardWidth = 640;

// A card that opens from a control near the bottom of the screen and covers it, with the
// screen dimmed behind. Tapping outside, Android back, or the iOS escape gesture closes it.
// Lighter than a sheet for a short list the reader glances at and leaves.
export function PopoverCard({
anchorRef,
children,
fill = false,
initialFocusRef,
label,
onClose,
onRequestClose,
placement = "cover",
returnFocusRef,
scrollable = true,
visible,
}: {
// The control the card opens from. "cover" pins the card's bottom edge to the control's
// bottom edge, over it; "above" opens just above it, leaving the control in view.
anchorRef: RefObject<View | null>;
placement?: "cover" | "above";
children: ReactNode;
// Use the full available height, for content that sizes itself to its container.
fill?: boolean;
initialFocusRef?: RefObject<Text | View | null> | undefined;
label: string;
onClose: () => void;
// Android back and the iOS escape gesture. Defaults to onClose; an inner page passes Back.
onRequestClose?: (() => void) | undefined;
returnFocusRef: RefObject<View | null>;
scrollable?: boolean;
visible: boolean;
}) {
const { height, width } = useWindowDimensions();
const insets = useSafeAreaInsets();
const { anchor, measure } = useAnchorFrame(anchorRef, visible, width, height);
const cardRef = useRef<View>(null);
// iOS returns focus in onDismiss; Android has no dismiss event, so it returns on close.
useReturnFocusAfterClose(visible, returnFocusRef);

const bottomEdge = anchor
? placement === "above"
? anchor.top - space.xs
: anchor.bottom
: height - insets.bottom - space.xl;
const bottom = Math.max(height - bottomEdge, insets.bottom + space.sm);
// Never taller than the space above its bottom edge, so it can't run under the status bar.
const maxHeight = Math.max(0, height - bottom - insets.top - space.md);
const side = space.md;
const cardWidth = Math.min(
width - Math.max(side, insets.left + space.sm) - Math.max(side, insets.right + space.sm),
maxCardWidth,
);
const leftEdge = Math.max(side, insets.left + space.sm);
const rightEdge = width - Math.max(side, insets.right + space.sm);
// Centered when there's room, and always clear of a notch on either side.
const left = Math.min(Math.max(leftEdge, (width - cardWidth) / 2), rightEdge - cardWidth);

return (
<Modal
animationType="fade"
navigationBarTranslucent
onDismiss={() => focusAccessibility(returnFocusRef.current)}
onRequestClose={onRequestClose ?? onClose}
onShow={() => {
// Opening can fold the composer under the anchor (its keyboard-less focus ends), which
// moves the anchor without any keyboard event; measure again once the card is up.
measure();
focusAccessibility(initialFocusRef?.current ?? cardRef.current);
}}
statusBarTranslucent
supportedOrientations={["portrait", "landscape"]}
transparent
visible={visible}
>
<Pressable
accessibilityLabel={`Close ${label}`}
accessibilityRole="button"
onPress={onClose}
style={styles.backdrop}
testID="popover-backdrop"
/>
<View
ref={cardRef}
accessibilityLabel={label}
accessibilityViewIsModal
onAccessibilityEscape={onRequestClose ?? onClose}
style={[
styles.card,
{ bottom, left, maxHeight, width: cardWidth },
fill && { height: maxHeight },
]}
testID="popover-card"
>
{scrollable ? (
<ScrollView
bounces={false}
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
>
{children}
</ScrollView>
) : (
<View style={[styles.content, styles.fixed]}>{children}</View>
)}
</View>
</Modal>
);
}

// Where the anchor sits in the window while the card is open. Measured again when the window
// changes size or the keyboard moves, so the card keeps its place against the anchor.
function useAnchorFrame(
anchorRef: RefObject<View | null>,
visible: boolean,
width: number,
height: number,
) {
const [anchor, setAnchor] = useState<{ bottom: number; top: number }>();
const measure = () =>
anchorRef.current?.measureInWindow((_x, y, _width, anchorHeight) => {
if (anchorHeight > 0) setAnchor({ bottom: y + anchorHeight, top: y });
});
// biome-ignore lint/correctness/useExhaustiveDependencies: width and height re-run the measure on rotation and resize.
useEffect(() => {
if (!visible) {
setAnchor(undefined);
return;
}
const remeasure = () =>
anchorRef.current?.measureInWindow((_x, y, _width, anchorHeight) => {
if (anchorHeight > 0) setAnchor({ bottom: y + anchorHeight, top: y });
});
remeasure();
const shown = Keyboard.addListener("keyboardDidShow", remeasure);
const hidden = Keyboard.addListener("keyboardDidHide", remeasure);
return () => {
shown.remove();
hidden.remove();
};
}, [anchorRef, visible, width, height]);
return { anchor, measure };
}

const styles = StyleSheet.create({
backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.45)" },
card: {
backgroundColor: palette.card,
borderColor: palette.border,
borderRadius: radius.lg,
borderWidth: StyleSheet.hairlineWidth,
...floatingShadow,
overflow: "hidden",
position: "absolute",
},
content: { gap: space.md, paddingVertical: space.md },
fixed: { flex: 1 },
});
4 changes: 2 additions & 2 deletions apps/mobile/src/components/working-indicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ const dots = [0, 1, 2, 3, 4, 5].map((bit) => ({

// The one working animation, shared by the session list and the session screen. The inbox
// passes its blue working color so the spinner matches the row's status text. Only the Working
// line and the background-work chip animate; other running glyphs pass animated={false} and keep
// the still first frame, so one screen never has a field of spinners ticking out of step.
// line and the background-work chip (while a subagent runs) animate; other running glyphs pass
// animated={false} and keep the still first frame, so a screen never fills with spinners.
export function WorkingIndicator({
animated = true,
color = palette.activity,
Expand Down
Loading
Loading