From e7e50f60cf672f81a13b3e6877bf4b29ceeda035 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 19 Sep 2026 10:57:37 +0000 Subject: [PATCH] fix(mobile): enlarge session filter and clear-search tap targets Explorer finding: a control is too small to tap reliably The user-agent explorer found this while using the app like a user. One finding per item; the explorer never edits product code. Flow: accessibility Found on revision: 59ef790e2 Repro: 1. set this state first: account e2e-mobile-cloud-android@example.com signed in, $25 credits, a second organization, 4 Code Reviewer pull requests, one top-level session `permission` (ses_...), system night mode, device landscape.; the device in dark mode 2. open the app on emulator-5554 3. reach the screen the capture names (the screen the capture names) 4. the capture shows the defect named below Observed: controls below 28dp on a side: agents-list-landscape: 1 small control(s): Filter sessions (52x52px) agents-search-empty: 2 small control(s): Filter sessions (52x52px); Clear search (42x42px) Expected: every control is at least 28dp on a side Evidence (from the device run): - ~/.local/share/kwf/findings/explorer-a-control-is-too-small-to-tap-reliably-aab15aeb/agents-list-landscape.png - ~/.local/share/kwf/findings/explorer-a-control-is-too-small-to-tap-reliably-aab15aeb/agents-search-empty.png - ~/.local/share/kwf/findings/explorer-a-control-is-too-small-to-tap-reliably-aab15aeb/preferences-account.png - ~/.local/share/kwf/findings/explorer-a-control-is-too-small-to-tap-reliably-aab15aeb/preferences-bottom.png --- .../agents/session-filter-button.tsx | 6 +- .../session-list-controls.mounted.test.tsx | 153 ++++++++++++++++++ .../agents/session-list-search-header.tsx | 9 +- 3 files changed, 160 insertions(+), 8 deletions(-) create mode 100644 apps/mobile/src/components/agents/session-list-controls.mounted.test.tsx diff --git a/apps/mobile/src/components/agents/session-filter-button.tsx b/apps/mobile/src/components/agents/session-filter-button.tsx index a2fdba02de..d34477b277 100644 --- a/apps/mobile/src/components/agents/session-filter-button.tsx +++ b/apps/mobile/src/components/agents/session-filter-button.tsx @@ -30,8 +30,6 @@ export function SessionFilterButton({ return ( {isActive ? ( @@ -48,7 +46,7 @@ export function SessionFilterButton({ // whole 44pt target on the Pressable underneath. ({ Pressable: 'Pressable', TextInput: 'TextInput', View: 'View' })); +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 47, right: 59 }), +})); +vi.mock('@/components/ui/icons', () => ({ + SlidersHorizontal: 'SlidersHorizontal', + Search: 'Search', + X: 'X', +})); +vi.mock('@/components/ui/activity-indicator', () => ({ ActivityIndicator: 'ActivityIndicator' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ mutedForeground: '#000000', foreground: '#111111' }), +})); + +const renderers: TestRenderer.ReactTestRenderer[] = []; +const { compile } = createRequire(import.meta.url)( + 'react-native-css/compiler' +) as typeof NativeCSSCompiler; + +const searchProps = { + inputRef: createRef(), + hasText: false, + showSearchBusy: false, + onChangeText: () => undefined, + onClearSearch: () => undefined, +}; + +async function nativeDimensions(node: TestRenderer.ReactTestInstance) { + const dimensions = (node.props.className as string) + .split(' ') + .filter(className => /^(?:h|w)-/.test(className)) + .join(' '); + const { css } = await postcss([tailwindcss()]).process( + `@reference "../../global.css"; .target { @apply ${dimensions}; }`, + { from: import.meta.filename } + ); + // Match Metro's options, including the compiler's default 14-point inlineRem. + const rules = compile(css, { inlineVariables: false }).stylesheet().s; + return rules?.find(([name]) => name === 'target')?.[1].flatMap(rule => rule.d ?? []); +} + +async function mount(element: ReactElement) { + await act(() => { + renderers.push(TestRenderer.create(element)); + }); + const renderer = renderers.at(-1); + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +describe('Session list control touch targets', () => { + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + }); + afterEach(() => { + act(() => { + for (const renderer of renderers.splice(0)) { + renderer.unmount(); + } + }); + }); + + it.each([0, 2])( + 'has a 44-point target with %s active filters without relying on hitSlop', + async activeCount => { + const onPress = vi.fn<() => void>(); + const renderer = await mount( + + ); + const button = renderer.root.findByType('Pressable'); + expect(await nativeDimensions(button)).toEqual([{ height: 44, width: 44 }]); + expect(button.props.className).toContain('shrink-0'); + expect(button.props.hitSlop).toBeUndefined(); + expect(button.props.accessibilityRole).toBe('button'); + expect(button.props.accessibilityLabel).toBe( + activeCount ? `Filter sessions, ${activeCount}` : 'Filter sessions' + ); + await act(() => { + (button.props.onPress as () => void)(); + }); + expect(onPress).toHaveBeenCalledOnce(); + if (activeCount) { + const badge = renderer.root.findByProps({ testID: 'session-filter-badge' }); + expect(badge.parent?.props.pointerEvents).toBe('none'); + } else { + expect(renderer.root.findAllByProps({ testID: 'session-filter-badge' })).toHaveLength(0); + } + } + ); + + it.each([false, true])( + 'keeps a 44-point clear target and its action while search is busy=%s', + async showSearchBusy => { + const onClearSearch = vi.fn<() => void>(); + const renderer = await mount( + + ); + const clear = renderer.root.findByType('Pressable'); + expect(await nativeDimensions(clear)).toEqual([{ height: 44, width: 44 }]); + expect(clear.props.className).toContain('shrink-0'); + expect(clear.props.accessibilityRole).toBe('button'); + expect(clear.props.accessibilityLabel).toBe('Clear search'); + expect(clear.props.hitSlop).toBeUndefined(); + await act(() => { + (clear.props.onPress as () => void)(); + }); + expect(onClearSearch).toHaveBeenCalledOnce(); + expect(renderer.root.findAllByType('ActivityIndicator')).toHaveLength(showSearchBusy ? 1 : 0); + } + ); + + it('reserves the clear target space without exposing an action for an empty query', async () => { + const renderer = await mount(); + const input = renderer.root.findByType('TextInput'); + const rowClasses = input.parent?.props.className; + await act(() => { + renderer.update(); + }); + expect(renderer.root.findByType('TextInput')).toBe(input); + expect(input.parent?.props.className).toBe(rowClasses); + expect(renderer.root.findAllByType('Pressable')).toHaveLength(0); + const spacer = input.parent?.children.at(-1); + if (!spacer || typeof spacer === 'string') { + throw new Error('Missing clear target spacer'); + } + expect(spacer.type).toBe('View'); + expect(await nativeDimensions(spacer)).toEqual([{ height: 44, width: 44 }]); + expect(spacer.props.className).toContain('shrink-0'); + expect(spacer.props.pointerEvents).toBe('none'); + }); +}); diff --git a/apps/mobile/src/components/agents/session-list-search-header.tsx b/apps/mobile/src/components/agents/session-list-search-header.tsx index b8b037e007..213d2b3e5b 100644 --- a/apps/mobile/src/components/agents/session-list-search-header.tsx +++ b/apps/mobile/src/components/agents/session-list-search-header.tsx @@ -44,7 +44,7 @@ export function SessionListSearchHeader({ {/* Fixed-size slot: the spinner swaps in for the icon, so the row never reflows. */} @@ -78,12 +78,13 @@ export function SessionListSearchHeader({ onPress={onClearSearch} accessibilityLabel={t('common.clearSearch')} accessibilityRole="button" - hitSlop={12} - className="active:opacity-70" + className="h-[44px] w-[44px] shrink-0 items-center justify-center active:opacity-70" > - ) : null} + ) : ( + + )} );