Skip to content
Closed
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
52 changes: 34 additions & 18 deletions apps/mobile/src/components/agents/session-filter-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ import { useTranslation } from 'react-i18next';

import { filterButtonAccessibilityLabel } from '@/components/agents/session-filter-button-label';
import { Text } from '@/components/ui/text';
import {
hitSlopPerSide,
MIN_TAP_TARGET_CLASS,
MIN_TAP_TARGET_DP,
TOUCH_TARGET_DP,
} from '@/lib/a11y/tap-target';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { cn } from '@/lib/utils';

type SessionFilterButtonProps = {
/** How many filters are applied. Zero renders the plain muted icon. */
Expand All @@ -30,8 +37,10 @@ export function SessionFilterButton({
return (
<Pressable
onPress={onPress}
// left slop capped against the 16px gap, right slop reaches 44pt wide
hitSlop={{ top: 12, bottom: 12, left: 8, right: 16 }}
// The icon sits in a 28dp box so the control's own accessibility node is
// at least 28dp; the slop tops the touch region up to the 44pt design
// minimum without moving the neighbours.
hitSlop={hitSlopPerSide(MIN_TAP_TARGET_DP, TOUCH_TARGET_DP)}
accessibilityRole="button"
// The count is spoken as part of the name, so no new translated string is
// needed to announce "Filter sessions, 2".
Expand All @@ -40,24 +49,31 @@ export function SessionFilterButton({
activeCount
)}
testID={testID}
className="active:opacity-70"
className={cn('active:opacity-70', MIN_TAP_TARGET_CLASS)}
>
<SlidersHorizontal size={20} color={isActive ? colors.foreground : colors.mutedForeground} />
{isActive ? (
// Overlaps the icon's top-right corner; `pointer-events-none` keeps the
// whole 44pt target on the Pressable underneath.
<View
pointerEvents="none"
className="absolute -right-1.5 -top-1.5 h-[15px] min-w-[15px] items-center justify-center rounded-full bg-primary px-1"
>
<Text
className="font-mono-medium text-[10px] leading-[normal] text-primary-foreground"
testID="session-filter-badge"
{/* Fixed 20dp slot the badge is pinned to, so centering the icon inside
the 28dp box never drifts the badge off the icon's corner. */}
<View className="h-[20px] w-[20px] items-center justify-center">
<SlidersHorizontal
size={20}
color={isActive ? colors.foreground : colors.mutedForeground}
/>
{isActive ? (
// Overlaps the icon's top-right corner; `pointer-events-none` keeps the
// whole target on the Pressable underneath.
<View
pointerEvents="none"
className="absolute -right-1.5 -top-1.5 h-[15px] min-w-[15px] items-center justify-center rounded-full bg-primary px-1"
>
{activeCount}
</Text>
</View>
) : null}
<Text
className="font-mono-medium text-[10px] leading-[normal] text-primary-foreground"
testID="session-filter-badge"
>
{activeCount}
</Text>
</View>
) : null}
</View>
</Pressable>
);
}
13 changes: 11 additions & 2 deletions apps/mobile/src/components/agents/session-list-search-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@ import { ActivityIndicator } from '@/components/ui/activity-indicator';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next';

import {
hitSlopPerSide,
MIN_TAP_TARGET_CLASS,
MIN_TAP_TARGET_DP,
TOUCH_TARGET_DP,
} from '@/lib/a11y/tap-target';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { cn } from '@/lib/utils';

type SessionListSearchHeaderProps = {
inputRef: RefObject<TextInput | null>;
Expand Down Expand Up @@ -78,8 +85,10 @@ export function SessionListSearchHeader({
onPress={onClearSearch}
accessibilityLabel={t('common.clearSearch')}
accessibilityRole="button"
hitSlop={12}
className="active:opacity-70"
// The icon sits in a 28dp box so the control's own accessibility node
// is at least 28dp; the slop tops the touch region up to 44pt.
hitSlop={hitSlopPerSide(MIN_TAP_TARGET_DP, TOUCH_TARGET_DP)}
className={cn('active:opacity-70', MIN_TAP_TARGET_CLASS)}
>
<X size={16} color={colors.mutedForeground} />
</Pressable>
Expand Down
110 changes: 110 additions & 0 deletions apps/mobile/src/components/agents/session-tap-targets.mounted.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { createRef, type ElementType, type ReactElement } from 'react';
import { act, TestRenderer } from '@/test/renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { type TextInput } from 'react-native';

import '@/i18n';
import { hitSlopPerSide, MIN_TAP_TARGET_DP, TOUCH_TARGET_DP } from '@/lib/a11y/tap-target';
import { SessionFilterButton } from './session-filter-button';
import { SessionListSearchHeader } from './session-list-search-header';

vi.mock('react-native', () => ({
Pressable: 'Pressable',
TextInput: 'TextInput',
View: 'View',
}));
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
}));
vi.mock('@/components/ui/icons', () => ({
Search: 'Search',
SlidersHorizontal: 'SlidersHorizontal',
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: () => ({
foreground: '#111111',
mutedForeground: '#666666',
primary: '#000000',
primaryForeground: '#ffffff',
}),
}));

const renderers: TestRenderer.ReactTestRenderer[] = [];

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;
}

function pressables(renderer: TestRenderer.ReactTestRenderer) {
return renderer.root.findAll(node => node.type === ('Pressable' as ElementType));
}

type Pressable = ReturnType<typeof pressables>[number];

/** The single Pressable the surface renders, or a failure naming the miss. */
function onlyPressable(renderer: TestRenderer.ReactTestRenderer): Pressable {
const buttons = pressables(renderer);
expect(buttons).toHaveLength(1);
const button = buttons.at(0);
if (!button) {
throw new Error('control was not rendered');
}
return button;
}

/**
* The finding's bar is the control's own box (its accessibility node, which
* `hitSlop` never grows); DESIGN.md then wants the touch region up to 44pt.
*/
function expectMinimumTapTarget(pressable: Pressable) {
const classes = pressable.props.className as string;
expect(classes).toContain(`min-h-[${MIN_TAP_TARGET_DP}px]`);
expect(classes).toContain(`min-w-[${MIN_TAP_TARGET_DP}px]`);
const slop = pressable.props.hitSlop;
expect(slop).toBe(hitSlopPerSide(MIN_TAP_TARGET_DP));
expect(MIN_TAP_TARGET_DP + (slop as number) * 2).toBeGreaterThanOrEqual(TOUCH_TARGET_DP);
}

describe('agents-list icon controls meet the minimum tap target', () => {
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('lays the filter button out in a box at least 28dp on a side', async () => {
const renderer = await mount(
<SessionFilterButton activeCount={1} onPress={() => undefined} testID="agents-open-filters" />
);
expectMinimumTapTarget(onlyPressable(renderer));
});

it('lays the clear-search button out in a box at least 28dp on a side', async () => {
const renderer = await mount(
<SessionListSearchHeader
inputRef={createRef<TextInput | null>()}
hasText
showSearchBusy={false}
onChangeText={() => undefined}
onClearSearch={() => undefined}
/>
);
expectMinimumTapTarget(onlyPressable(renderer));
});
});
30 changes: 30 additions & 0 deletions apps/mobile/src/lib/a11y/tap-target.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';

import {
hitSlopPerSide,
MIN_TAP_TARGET_CLASS,
MIN_TAP_TARGET_DP,
TOUCH_TARGET_DP,
} from './tap-target';

describe('tap-target geometry', () => {
it('declares a 28dp minimum box and names it in the class string', () => {
expect(MIN_TAP_TARGET_DP).toBe(28);
expect(MIN_TAP_TARGET_CLASS).toContain(`min-h-[${MIN_TAP_TARGET_DP}px]`);
expect(MIN_TAP_TARGET_CLASS).toContain(`min-w-[${MIN_TAP_TARGET_DP}px]`);
expect(MIN_TAP_TARGET_CLASS).toContain('items-center');
expect(MIN_TAP_TARGET_CLASS).toContain('justify-center');
});

it('lifts the minimum box to the 44pt design target on every side', () => {
expect(TOUCH_TARGET_DP).toBe(44);
expect(MIN_TAP_TARGET_DP + hitSlopPerSide(MIN_TAP_TARGET_DP) * 2).toBeGreaterThanOrEqual(
TOUCH_TARGET_DP
);
});

it('never returns a negative slop for a control already at the target', () => {
expect(hitSlopPerSide(TOUCH_TARGET_DP)).toBe(0);
expect(hitSlopPerSide(60)).toBe(0);
});
});
25 changes: 25 additions & 0 deletions apps/mobile/src/lib/a11y/tap-target.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Tap-target geometry for compact controls.
//
// A control's accessibility node is its laid-out box. `hitSlop` widens the
// touch region but never the reported bounds, so an icon that leans on
// `hitSlop` alone is still exposed to screen readers — and to the platform's
// target-size audit — as a sub-minimum control. Lay the icon out inside a
// `MIN_TAP_TARGET_CLASS` box, then top the touch region up to the design
// minimum (DESIGN.md: 44px on touch surfaces) with `hitSlopPerSide`.

/** Minimum side of a control's own layout box, in dp. */
export const MIN_TAP_TARGET_DP = 28;

/**
* Literal NativeWind classes that lay a compact icon out inside a
* `MIN_TAP_TARGET_DP` box. Kept literal so the class scanner sees them.
*/
export const MIN_TAP_TARGET_CLASS = 'min-h-[28px] min-w-[28px] items-center justify-center';

/** The design minimum touch target, in dp (DESIGN.md). */
export const TOUCH_TARGET_DP = 44;

/** Per-side `hitSlop` that lifts a `visualDp` box to `targetDp`; never negative. */
export function hitSlopPerSide(visualDp: number, targetDp: number = TOUCH_TARGET_DP): number {
return Math.max(0, Math.ceil((targetDp - visualDp) / 2));
}
Loading