diff --git a/docs/accessibility.md b/docs/accessibility.md
index 2f54556..e9ed32c 100644
--- a/docs/accessibility.md
+++ b/docs/accessibility.md
@@ -6,6 +6,7 @@ Switchify Remote is designed for VoiceOver, TalkBack, iOS Switch Control, and An
- First-run setup explains the Remote before asking for Bluetooth. Its two steps expose headings and "Step 1 of 2"/"Step 2 of 2" announcements in logical reading order, remain scrollable at large text sizes, and never move focus to a permission prompt until Allow Bluetooth is selected.
- Surface selection is one button that announces its current value and opens a modal option list. Options expose selected state, scanning stays inside the modal, and focus returns to the selector after selection or dismissal. Toggles expose selected or disabled state.
- On Remote screens that expose controls, the Surface selector stays at the top of the scroll area after the title scrolls away. Its backdrop never becomes a scanning stop, and pinning does not move focus or trigger an announcement.
+- Connected Remote control screens add one "Scroll to top" scan stop after the Surface selector pins. The 48-point control stays above the PC switcher, announces "Top of Remote" once after use, and does not animate when Reduce Motion is enabled.
- Remote keeps a quick PC switcher immediately above the tab bar in every connection state. It announces the active connection, exposes selected state for the current saved PC, and restores focus after its modal closes.
- Headings, connection changes, failures, repeat state, pairing approval, and pointer-profile recovery transitions are announced once without moving focus unexpectedly.
- The pairing code is announced one digit at a time.
@@ -25,4 +26,4 @@ Switchify Remote is designed for VoiceOver, TalkBack, iOS Switch Control, and An
## Manual smoke test
-On both platforms, clear the app data before starting. Enable the screen reader and then the platform switch-access feature. Traverse both setup steps and confirm the headings, step positions, explanations, desktop download, Continue, Back, Not now, and Allow Bluetooth are announced in logical order. Confirm no Bluetooth prompt appears before Allow Bluetooth, and that the layout remains usable in both orientations and at 200% text. Then navigate PCs, pairing, every available remote screen, Settings, and Diagnostics in logical order. Confirm every action can be selected, read-only text does not become a switch-scan stop, pointer repeat exposes Stop movement, held modifiers and drag expose state, disabled controls are announced, and backgrounding returns the desktop to a neutral input state. On each Remote control screen, scroll past the title and confirm the Surface selector stays visible, gains its backdrop only while pinned, remains one scan stop, and causes no focus move or announcement. On Android, confirm Forwarding keeps its profile choices available before Start but adds mapped external switches to scanning only while forwarding is active. The mapped switches and overflow notice must disappear after manual or safety stops. In Settings, edit the Remote name and confirm validation and save status are announced without adding read-only scan stops. Confirm Use device model restores the model-based name. Confirm Privacy policy is one scan stop, announces that it opens in a browser, opens the Remote-specific policy, and returns safely if the browser cannot open. During a delayed pointer-profile response, confirm "Restoring controls" is announced once, followed by either restored controls or "Controls unavailable" after all retries. Open the Surface selector while it is pinned and confirm the underlying screen and bottom tabs are not scannable, options scan linearly, Close works, Android back and VoiceOver escape dismiss the modal, and focus returns to the selector after selection or dismissal. Repeat those checks with the Remote PC switcher, confirm it stays above the tabs while content scrolls, and switch between two saved PCs. Press Unpair for a saved computer and confirm the native alert reads the full computer name. Verify Cancel, Android Back, and outside dismissal preserve the pairing, then confirm Unpair removes it. Repeat in light and dark appearance, at 200% text scaling, with reduced motion enabled, and with iOS Reduce Transparency enabled.
+On both platforms, clear the app data before starting. Enable the screen reader and then the platform switch-access feature. Traverse both setup steps and confirm the headings, step positions, explanations, desktop download, Continue, Back, Not now, and Allow Bluetooth are announced in logical order. Confirm no Bluetooth prompt appears before Allow Bluetooth, and that the layout remains usable in both orientations and at 200% text. Then navigate PCs, pairing, every available remote screen, Settings, and Diagnostics in logical order. Confirm every action can be selected, read-only text does not become a switch-scan stop, pointer repeat exposes Stop movement, held modifiers and drag expose state, disabled controls are announced, and backgrounding returns the desktop to a neutral input state. On each connected Remote control screen, scroll past the title and confirm the Surface selector stays visible, gains its backdrop only while pinned, remains one scan stop, and causes no focus move or announcement. Confirm Scroll to top then appears as one 48-point scan stop above the PC switcher, does not cover the final control, returns to the title, announces completion once, and disappears at the top. Interrupt the return gesture and confirm it remains usable without a stale announcement. Repeat with Reduce Motion and confirm the return is immediate. Confirm disconnected, recovering, and unavailable states do not expose Scroll to top. On Android, confirm Forwarding keeps its profile choices available before Start but adds mapped external switches to scanning only while forwarding is active. The mapped switches and overflow notice must disappear after manual or safety stops. In Settings, edit the Remote name and confirm validation and save status are announced without adding read-only scan stops. Confirm Use device model restores the model-based name. Confirm Privacy policy is one scan stop, announces that it opens in a browser, opens the Remote-specific policy, and returns safely if the browser cannot open. During a delayed pointer-profile response, confirm "Restoring controls" is announced once, followed by either restored controls or "Controls unavailable" after all retries. Open the Surface selector while it is pinned and confirm the underlying screen and bottom tabs are not scannable, options scan linearly, Close works, Android back and VoiceOver escape dismiss the modal, and focus returns to the selector after selection or dismissal. Repeat those checks with the Remote PC switcher, confirm it stays above the tabs while content scrolls, and switch between two saved PCs. Press Unpair for a saved computer and confirm the native alert reads the full computer name. Verify Cancel, Android Back, and outside dismissal preserve the pairing, then confirm Unpair removes it. Repeat in light and dark appearance, at 200% text scaling, with reduced motion enabled, and with iOS Reduce Transparency enabled.
diff --git a/docs/physical-smoke-test.md b/docs/physical-smoke-test.md
index 2277e68..4b6675e 100644
--- a/docs/physical-smoke-test.md
+++ b/docs/physical-smoke-test.md
@@ -21,6 +21,7 @@ Run the matrix on a physical Android phone and iPhone against current Switchify
- Rename a paired PC to a long fixture name. Confirm the Remote status, PC card, and Unpair control wrap without truncation.
- Open each Surface, Opening surface, and Hold to stop selector. Confirm only the selector is a scan stop while closed; while open, only the modal options and Close are scanned. Confirm selection, Close, Android back, scrim dismissal, and VoiceOver escape restore focus to the selector.
- Scroll Mouse, Typing, Window, and Android Forwarding past the Remote title. Confirm the Surface selector pins below the top safe area, the controls blur only while passing behind it, and the fixed PC switcher remains above the tab bar. Confirm pinning adds no scan stop, focus move, or announcement.
+ - On each connected control screen, confirm Scroll to top appears only after the selector pins, remains above the PC switcher at phone and tablet widths, and leaves the last control unobscured. Activate it twice quickly and confirm only one return occurs. Confirm it announces "Top of Remote" once, disappears at the top, cancels cleanly when the user interrupts the scroll, and jumps without animation under Reduce Motion. Confirm it is absent from disconnected, recovering, and unavailable states.
- On Android 12 or later, confirm the pinned backdrop visibly blurs the moving controls without slowing pointer repeat, typing, or Forwarding. On Android 11, confirm the themed fallback remains legible and responsive. On iOS, enable Reduce Transparency and confirm the pinned backdrop becomes opaque without changing its size or focus order.
- Repeat on a tablet at normal and maximum text size. Confirm two-column and two-pane layouts collapse when enlarged text needs the width.
9. While repeat, drag, modifiers, and a text stream are active, background and terminate the app, disconnect Bluetooth, and quit Switchify PC. Confirm the desktop returns to neutral input state each time.
diff --git a/src/app/(tabs)/remote.tsx b/src/app/(tabs)/remote.tsx
index 3f51937..770020f 100644
--- a/src/app/(tabs)/remote.tsx
+++ b/src/app/(tabs)/remote.tsx
@@ -48,7 +48,7 @@ export default function RemoteScreen() {
return ;
}
return (
- } bottomAccessory={deviceSwitcher} stickyAccessory={}>
+ } bottomAccessory={deviceSwitcher} scrollToTop stickyAccessory={}>
{preferences.surface === 'mouse' ? 0} /> : null}
{preferences.surface === 'typing' ? : null}
{preferences.surface === 'window' ? : null}
diff --git a/src/components/Screen.tsx b/src/components/Screen.tsx
index 0dbcc9a..5baf752 100644
--- a/src/components/Screen.tsx
+++ b/src/components/Screen.tsx
@@ -1,9 +1,10 @@
import { BlurTargetView } from 'expo-blur';
import { type PropsWithChildren, type ReactNode, useCallback, useRef, useState } from 'react';
-import { ScrollView, View, type NativeScrollEvent, type NativeSyntheticEvent } from 'react-native';
+import { AccessibilityInfo, ScrollView, View, type NativeScrollEvent, type NativeSyntheticEvent } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { AppText } from './AppText';
+import { ScrollToTopButton } from './ScrollToTopButton';
import { StickyBackdrop } from './StickyBackdrop';
import { useLayout, useTheme } from '@/theme/ThemeContext';
@@ -13,6 +14,7 @@ type ScreenProps = PropsWithChildren<{
headerAccessory?: ReactNode;
bottomAccessory?: ReactNode;
nativeHeader?: boolean;
+ scrollToTop?: boolean;
stickyAccessory?: ReactNode;
}>;
@@ -34,17 +36,21 @@ type StickyScreenContentProps = PropsWithChildren<{
nativeHeader: boolean;
paddingBottom: number;
paddingHorizontal: number;
+ scrollToTop: boolean;
stackHeader: boolean;
stickyAccessory: ReactNode;
title: string;
}>;
-function StickyScreenContent({ children, description, headerAccessory, isExpanded, nativeHeader, paddingBottom, paddingHorizontal, stackHeader, stickyAccessory, title }: StickyScreenContentProps) {
- const { spacing } = useTheme();
+function StickyScreenContent({ children, description, headerAccessory, isExpanded, nativeHeader, paddingBottom, paddingHorizontal, scrollToTop, stackHeader, stickyAccessory, title }: StickyScreenContentProps) {
+ const { reducedMotion, spacing } = useTheme();
const [pinned, setPinned] = useState(false);
+ const [scrollingToTop, setScrollingToTop] = useState(false);
const blurTarget = useRef(null);
const pinnedRef = useRef(false);
+ const scrollRef = useRef(null);
const scrollOffsetRef = useRef(0);
+ const scrollToTopPendingRef = useRef(false);
const stickyTopRef = useRef(null);
const maxWidth = isExpanded ? 960 : 640;
@@ -59,12 +65,33 @@ function StickyScreenContent({ children, description, headerAccessory, isExpande
const handleScroll = useCallback((event: NativeSyntheticEvent) => {
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
updatePinned();
+ if (scrollToTopPendingRef.current && scrollOffsetRef.current <= 1) {
+ scrollToTopPendingRef.current = false;
+ setScrollingToTop(false);
+ AccessibilityInfo.announceForAccessibilityWithOptions('Top of Remote', { queue: true });
+ }
}, [updatePinned]);
- return {
+ if (!scrollToTopPendingRef.current) return;
+ scrollToTopPendingRef.current = false;
+ setScrollingToTop(false);
+ }, []);
+
+ const handleScrollToTop = useCallback(() => {
+ if (scrollToTopPendingRef.current || scrollRef.current === null) return;
+ scrollToTopPendingRef.current = true;
+ setScrollingToTop(true);
+ scrollRef.current.scrollTo({ animated: !reducedMotion, y: 0 });
+ }, [reducedMotion]);
+
+ const scrollView =
@@ -86,9 +113,20 @@ function StickyScreenContent({ children, description, headerAccessory, isExpande
{children}
;
+
+ if (!scrollToTop) return scrollView;
+
+ return
+ {scrollView}
+ {pinned ?
+
+
+
+ : null}
+ ;
}
-export function Screen({ title, description, headerAccessory, bottomAccessory, nativeHeader = false, stickyAccessory, children }: ScreenProps) {
+export function Screen({ title, description, headerAccessory, bottomAccessory, nativeHeader = false, scrollToTop = false, stickyAccessory, children }: ScreenProps) {
const { colors, spacing } = useTheme();
const { isCompact, isExpanded, isLargeText } = useLayout();
const insets = useSafeAreaInsets();
@@ -109,6 +147,7 @@ export function Screen({ title, description, headerAccessory, bottomAccessory, n
nativeHeader={nativeHeader}
paddingBottom={paddingBottom}
paddingHorizontal={paddingHorizontal}
+ scrollToTop={scrollToTop}
stackHeader={stackHeader}
stickyAccessory={stickyAccessory}
title={title}
diff --git a/src/components/ScreenScrollToTop.test.tsx b/src/components/ScreenScrollToTop.test.tsx
new file mode 100644
index 0000000..b734a6f
--- /dev/null
+++ b/src/components/ScreenScrollToTop.test.tsx
@@ -0,0 +1,112 @@
+import { fireEvent, render } from '@testing-library/react-native';
+import { AccessibilityInfo, ScrollView, StyleSheet } from 'react-native';
+
+import { palettes, radii, spacing, typography } from '@/theme/tokens';
+import { ControlButton } from './ControlButton';
+import { Screen } from './Screen';
+
+const mockUseTheme = jest.fn();
+
+jest.mock('@/theme/ThemeContext', () => ({
+ useLayout: () => ({ fontScale: 1, isCompact: true, isExpanded: false, isLandscape: false, isLargeText: false, isMedium: false }),
+ useReducedMotionPreference: () => mockUseTheme().reducedMotion,
+ useTheme: () => mockUseTheme(),
+}));
+
+function theme(reducedMotion = false) {
+ return { colors: palettes.dark, radii, reducedMotion, reducedTransparency: false, scheme: 'dark' as const, spacing, typography };
+}
+
+async function renderScrollableScreen() {
+ return render(
+ undefined} />}
+ scrollToTop
+ stickyAccessory={ undefined} />}
+ title="Remote"
+ >
+ undefined} />
+ ,
+ );
+}
+
+async function pinSelector(view: Awaited>) {
+ const scroll = view.getByTestId('screen-scroll');
+ await fireEvent(view.getByTestId('screen-sticky-accessory'), 'layout', { nativeEvent: { layout: { height: 64, width: 350, x: 0, y: 100 } } });
+ await fireEvent.scroll(scroll, { nativeEvent: { contentOffset: { x: 0, y: 100 } } });
+ return scroll;
+}
+
+describe('Screen scroll-to-top control', () => {
+ beforeEach(() => {
+ mockUseTheme.mockReturnValue(theme());
+ jest.spyOn(AccessibilityInfo, 'announceForAccessibilityWithOptions').mockImplementation(() => undefined).mockClear();
+ });
+
+ afterEach(() => jest.restoreAllMocks());
+
+ it('appears only after pinning and returns to the top once', async () => {
+ const scrollTo = jest.spyOn(ScrollView.prototype, 'scrollTo').mockImplementation(() => undefined);
+ const view = await renderScrollableScreen();
+ const scroll = view.getByTestId('screen-scroll');
+
+ expect(view.queryByRole('button', { name: 'Scroll to top' })).toBeNull();
+ expect(StyleSheet.flatten(scroll.props.contentContainerStyle).paddingBottom).toBe(80);
+
+ await pinSelector(view);
+ const button = view.getByRole('button', { name: 'Scroll to top' });
+ const overlay = view.getByTestId('screen-scroll-to-top-overlay');
+ expect(StyleSheet.flatten(button.props.style)).toMatchObject({ height: 48, width: 48 });
+ expect(overlay.props.accessible).toBe(false);
+ expect(overlay.props.pointerEvents).toBe('box-none');
+ expect(view.getByTestId('screen-scroll-to-top-icon', { includeHiddenElements: true }).props).toMatchObject({ accessible: false, importantForAccessibility: 'no' });
+ expect(view.getAllByRole('button')).toHaveLength(4);
+
+ await fireEvent.press(button);
+ await fireEvent.press(button);
+ expect(scrollTo).toHaveBeenCalledTimes(1);
+ expect(scrollTo).toHaveBeenCalledWith({ animated: true, y: 0 });
+ expect(view.getByRole('button', { name: 'Scroll to top' }).props.accessibilityState.disabled).toBe(true);
+
+ await fireEvent.scroll(scroll, { nativeEvent: { contentOffset: { x: 0, y: 0 } } });
+ expect(view.queryByRole('button', { name: 'Scroll to top' })).toBeNull();
+ expect(AccessibilityInfo.announceForAccessibilityWithOptions).toHaveBeenCalledTimes(1);
+ expect(AccessibilityInfo.announceForAccessibilityWithOptions).toHaveBeenCalledWith('Top of Remote', { queue: true });
+ });
+
+ it('jumps without animation when Reduce Motion is enabled', async () => {
+ mockUseTheme.mockReturnValue(theme(true));
+ const scrollTo = jest.spyOn(ScrollView.prototype, 'scrollTo').mockImplementation(() => undefined);
+ const view = await renderScrollableScreen();
+ await pinSelector(view);
+
+ await fireEvent.press(view.getByRole('button', { name: 'Scroll to top' }));
+ expect(scrollTo).toHaveBeenCalledWith({ animated: false, y: 0 });
+ });
+
+ it.each(['scrollBeginDrag', 'touchStart'] as const)('cancels pending completion on %s', async (eventName) => {
+ jest.spyOn(ScrollView.prototype, 'scrollTo').mockImplementation(() => undefined);
+ const view = await renderScrollableScreen();
+ const scroll = await pinSelector(view);
+ await fireEvent.press(view.getByRole('button', { name: 'Scroll to top' }));
+
+ await fireEvent(scroll, eventName);
+ expect(view.getByRole('button', { name: 'Scroll to top' }).props.accessibilityState.disabled).toBe(false);
+ await fireEvent.scroll(scroll, { nativeEvent: { contentOffset: { x: 0, y: 0 } } });
+ expect(AccessibilityInfo.announceForAccessibilityWithOptions).not.toHaveBeenCalled();
+ });
+
+ it('recomputes visibility after relayout and unmounts safely during a pending scroll', async () => {
+ jest.spyOn(ScrollView.prototype, 'scrollTo').mockImplementation(() => undefined);
+ const view = await renderScrollableScreen();
+ await pinSelector(view);
+ expect(view.getByRole('button', { name: 'Scroll to top' })).toBeTruthy();
+
+ await fireEvent(view.getByTestId('screen-sticky-accessory'), 'layout', { nativeEvent: { layout: { height: 96, width: 700, x: 0, y: 140 } } });
+ expect(view.queryByRole('button', { name: 'Scroll to top' })).toBeNull();
+ await fireEvent.scroll(view.getByTestId('screen-scroll'), { nativeEvent: { contentOffset: { x: 0, y: 150 } } });
+ await fireEvent.press(view.getByRole('button', { name: 'Scroll to top' }));
+ await view.unmount();
+ expect(AccessibilityInfo.announceForAccessibilityWithOptions).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/components/ScrollToTopButton.tsx b/src/components/ScrollToTopButton.tsx
new file mode 100644
index 0000000..5daa0e7
--- /dev/null
+++ b/src/components/ScrollToTopButton.tsx
@@ -0,0 +1,38 @@
+import MaterialIcons from '@expo/vector-icons/MaterialIcons';
+import { Animated, Pressable } from 'react-native';
+
+import { useTheme } from '@/theme/ThemeContext';
+import { usePressScale } from './usePressScale';
+
+export function ScrollToTopButton({ disabled, onPress }: { disabled: boolean; onPress: () => void }) {
+ const { colors, radii } = useTheme();
+ const press = usePressScale();
+
+ return (
+
+ ({
+ alignItems: 'center',
+ backgroundColor: pressed && !disabled ? colors.surfacePressed : colors.surfaceRaised,
+ borderColor: colors.borderStrong,
+ borderRadius: radii.pill,
+ borderWidth: 1,
+ height: 48,
+ justifyContent: 'center',
+ opacity: disabled ? 0.6 : 1,
+ width: 48,
+ })}
+ testID="screen-scroll-to-top"
+ >
+
+
+
+ );
+}
diff --git a/src/remote/RemoteScreen.test.tsx b/src/remote/RemoteScreen.test.tsx
index e95a91c..e021cca 100644
--- a/src/remote/RemoteScreen.test.tsx
+++ b/src/remote/RemoteScreen.test.tsx
@@ -22,7 +22,7 @@ const mockBridge = {
subscribe: jest.fn(() => () => undefined),
};
const mockBridgeSnapshot = { version: 1, captureAvailable: false, externalSwitches: [] };
-const mockPreferences = { surface: 'mouse' as const, typingMode: 'live' as const, draft: '', forwardingHoldToStopMs: 5_000, forwardingProfiles: {}, remoteName: null };
+let mockPreferences = { surface: 'mouse' as 'mouse' | 'typing' | 'window' | 'forwarding', typingMode: 'live' as const, draft: '', forwardingHoldToStopMs: 5_000, forwardingProfiles: {}, remoteName: null };
const mockSessionState = { repeat: null, dragging: false, modifiers: [], streamOpen: false };
let mockConnection: ConnectionState;
@@ -74,25 +74,41 @@ const profile: PointerProfile = {
describe('RemoteScreen sticky surface selector', () => {
beforeEach(() => {
mockConnection = { kind: 'connected', desktop, profile, profileStatus: 'ready' };
+ mockPreferences = { surface: 'mouse', typingMode: 'live', draft: '', forwardingHoldToStopMs: 5_000, forwardingProfiles: {}, remoteName: null };
jest.clearAllMocks();
});
it('pins the selector when connected controls are available', async () => {
const view = await render();
expect(view.getByTestId('screen-sticky-accessory')).toBeTruthy();
+ expect(view.getByTestId('screen-scroll-to-top-container')).toBeTruthy();
expect(view.getByRole('button', { name: 'Surface' })).toBeTruthy();
expect(view.getByText('Mouse controls')).toBeTruthy();
});
+ it.each([
+ ['mouse', 'Mouse controls'],
+ ['typing', 'Typing controls'],
+ ['window', 'Window controls'],
+ ['forwarding', 'Forwarding controls'],
+ ] as const)('enables scroll-to-top for the connected %s controls', async (surface, label) => {
+ mockPreferences = { ...mockPreferences, surface };
+ const view = await render();
+ expect(view.getByTestId('screen-scroll-to-top-container')).toBeTruthy();
+ expect(view.getByText(label)).toBeTruthy();
+ });
+
it('does not expose the selector while a profile is recovering or unavailable', async () => {
mockConnection = { kind: 'connected', desktop, profile: null, profileStatus: 'recovering' };
const view = await render();
expect(view.queryByTestId('screen-sticky-accessory')).toBeNull();
+ expect(view.queryByTestId('screen-scroll-to-top-container')).toBeNull();
expect(view.queryByRole('button', { name: 'Surface' })).toBeNull();
mockConnection = { kind: 'connected', desktop, profile: null, profileStatus: 'unavailable' };
await view.rerender();
expect(view.queryByTestId('screen-sticky-accessory')).toBeNull();
+ expect(view.queryByTestId('screen-scroll-to-top-container')).toBeNull();
expect(view.queryByRole('button', { name: 'Surface' })).toBeNull();
});
@@ -100,6 +116,7 @@ describe('RemoteScreen sticky surface selector', () => {
mockConnection = { kind: 'idle', saved: [] };
const view = await render();
expect(view.getByTestId('screen-sticky-accessory')).toBeTruthy();
+ expect(view.queryByTestId('screen-scroll-to-top-container')).toBeNull();
expect(view.getByRole('button', { name: 'Surface' })).toBeTruthy();
});
});