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
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// The agents "Filter sessions" control is one of the icon-only controls the
// accessibility explorer found below 28dp: it rendered the bare 20dp sliders
// icon, so its accessibility node was the icon. This pins the box that replaced
// it, and that the count badge still hangs off the icon.

import { createElement } from 'react';
import { act, TestRenderer } from '@/test/renderer';
import { describe, expect, it, vi } from 'vitest';

import { expectReliableTapTarget } from '@/test/touch-target.test-helpers';

import '@/i18n';
import { SessionFilterButton } from './session-filter-button';

vi.mock('react-native', () => ({
Pressable: 'Pressable',
View: 'View',
}));

vi.mock('@/components/ui/icons', () => ({
SlidersHorizontal: 'SlidersHorizontal',
}));

vi.mock('@/components/ui/text', () => ({ Text: 'Text' }));

vi.mock('@/lib/hooks/use-theme-colors', () => ({
useThemeColors: () => ({ foreground: '#000000', mutedForeground: '#666666' }),
}));

async function renderButton(activeCount: number): Promise<TestRenderer.ReactTestRenderer> {
const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined };
await act(async () => {
ref.current = TestRenderer.create(
createElement(SessionFilterButton, {
activeCount,
onPress: vi.fn<() => void>(),
testID: 'agents-open-filters',
})
);
await Promise.resolve();
});
const renderer = ref.current;
if (!renderer) {
throw new Error('renderer was not created');
}
return renderer;
}

function findFilterButton(root: TestRenderer.ReactTestInstance): TestRenderer.ReactTestInstance {
return root.find(
node => String(node.type) === 'Pressable' && node.props.testID === 'agents-open-filters'
);
}

describe('SessionFilterButton mounted', () => {
it('gives the filter control a box at least 28dp on a side and a 44pt tap target', async () => {
const renderer = await renderButton(0);

const button = findFilterButton(renderer.root);
expectReliableTapTarget(button.props);
expect(button.props.accessibilityLabel).toBe('Filter sessions');
});

it('keeps the badge and the spoken count when filters are applied', async () => {
const renderer = await renderButton(2);

const button = findFilterButton(renderer.root);
expectReliableTapTarget(button.props);
expect(button.props.accessibilityLabel).toBe('Filter sessions, 2');
expect(
renderer.root.findAll(node => node.props.testID === 'session-filter-badge')
).toHaveLength(1);
});
});
47 changes: 25 additions & 22 deletions apps/mobile/src/components/agents/session-filter-button.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { SlidersHorizontal } from '@/components/ui/icons';
import { Pressable, View } from 'react-native';
import { View } from 'react-native';
import { useTranslation } from 'react-i18next';

import { filterButtonAccessibilityLabel } from '@/components/agents/session-filter-button-label';
import { IconButton } from '@/components/ui/icon-button';
import { Text } from '@/components/ui/text';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';

Expand All @@ -28,36 +29,38 @@ export function SessionFilterButton({
const isActive = activeCount > 0;

return (
<Pressable
<IconButton
onPress={onPress}
// left slop capped against the 16px gap, right slop reaches 44pt wide
hitSlop={{ top: 12, bottom: 12, left: 8, right: 16 }}
accessibilityRole="button"
// The count is spoken as part of the name, so no new translated string is
// needed to announce "Filter sessions, 2".
accessibilityLabel={filterButtonAccessibilityLabel(
t('agentChat.sessionFilter.title'),
activeCount
)}
testID={testID}
className="active:opacity-70"
>
<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"
{/* Anchors the badge to the icon rather than to the 32pt touch box. */}
<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 touch 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}
</Pressable>
<Text
className="font-mono-medium text-[10px] leading-[normal] text-primary-foreground"
testID="session-filter-badge"
>
{activeCount}
</Text>
</View>
) : null}
</View>
</IconButton>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,20 @@ function filterButtonProps() {
}
return button.props as { accessibilityLabel?: string; accessibilityValue?: unknown };
}
/**
* Nearest ancestor whose className holds `token`. Each header control renders
* through its own component, so a control's parent chain depth is not fixed.
*/
function ancestorWithClassName(
node: TestRenderer.ReactTestInstance | undefined,
token: string
): TestRenderer.ReactTestInstance | null {
let current = node?.parent ?? null;
while (current && !String(current.props.className).includes(token)) {
current = current.parent;
}
return current;
}
function applyFilters(projectFilter: string[], platformFilter: string[]) {
act(() => {
headerAction('agents-open-filters').props.onPress();
Expand Down Expand Up @@ -1062,7 +1076,7 @@ describe('AgentSessionListScreen header and admission', () => {
const filters = nodes('Pressable').find(node => node.props.testID === 'agents-open-filters');
expect(history?.parent?.props.className).toContain('items-center');
expect(history?.parent?.props.className).toContain('min-h-11');
expect(filters?.parent?.parent).toBe(history?.parent);
expect(ancestorWithClassName(filters, 'min-h-11')).toBe(history?.parent);
const updating = nodes('Text').find(node => node.children.includes('Updating'));
expect(updating).toBeUndefined();
state.live.isFetching = true;
Expand Down
149 changes: 149 additions & 0 deletions apps/mobile/src/components/organization/hub-screen.mounted.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// The org hub's "Rename organization" pencil is one of the icon-only controls
// the accessibility explorer found below 28dp: it rendered the bare 16dp icon,
// so its accessibility node was the icon. Both routes that show the hub
// (organization/index and the organization/[org-id] deep link) render this same
// control, so this suite covers both.

import { createElement, type ReactNode } from 'react';
import { act, TestRenderer } from '@/test/renderer';
import { describe, expect, it, vi } from 'vitest';

import { expectReliableTapTarget } from '@/test/touch-target.test-helpers';

import '@/i18n';
import { OrganizationHubScreen } from './hub-screen';

vi.mock('react-native', () => ({
Pressable: 'Pressable',
View: 'View',
}));

vi.mock('react-native-reanimated', () => ({
default: { View: 'AnimatedView' },
FadeIn: { duration: () => ({}) },
}));

vi.mock('expo-router', () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));

vi.mock('expo-haptics', () => ({
notificationAsync: vi.fn(),
NotificationFeedbackType: { Success: 'success' },
}));

vi.mock('@/components/ui/icons', () => ({
Bell: 'Bell',
FileText: 'FileText',
Pencil: 'Pencil',
Receipt: 'Receipt',
Users: 'Users',
}));

vi.mock('@/components/ui/directional-icons', () => ({
DirectionalChevronRight: 'DirectionalChevronRight',
}));

vi.mock('@/components/ui/text', () => ({ Text: 'Text' }));

vi.mock('@/components/ui/configure-row', () => ({ ConfigureRow: 'ConfigureRow' }));

vi.mock('@/components/ui/kv-row', () => ({ KvRow: 'KvRow' }));

vi.mock('@/components/tab-screen', () => ({
TabScreenScrollView: (props: { children?: ReactNode }) =>
createElement('TabScreenScrollView', null, props.children),
}));

vi.mock('@/components/screen-header', () => ({
ScreenHeader: (props: { title?: string }) => createElement('ScreenHeader', null, props.title),
}));

vi.mock('@/components/rename-modal', () => ({ RenameModal: 'RenameModal' }));

vi.mock('@/components/add-credits-row', () => ({ AddCreditsRow: 'AddCreditsRow' }));

vi.mock('@/components/kilo-pass/kilo-pass-icon', () => ({ KiloPassIcon: 'KiloPassIcon' }));

vi.mock('@/components/organization/organization-boundary', () => ({
OrganizationBoundary: 'OrganizationBoundary',
}));

vi.mock('@/components/organization/org-usage-stats', () => ({ OrgUsageStats: 'OrgUsageStats' }));

vi.mock('@/components/organization/org-kilo-pass-row-state', () => ({
getOrgKiloPassRowState: () => null,
}));

vi.mock('@/lib/config', () => ({ WEB_BASE_URL: 'https://app.kilo.ai' }));

vi.mock('@/lib/external-link', () => ({ openExternalUrl: vi.fn() }));

vi.mock('@/lib/hooks/use-organization-mutations', () => ({
useOrganizationMutations: () => ({ rename: { mutateAsync: vi.fn() } }),
}));

vi.mock('@/lib/hooks/use-organization-queries', () => ({
isMoneyRole: () => true,
useOrgBoundary: () => ({
organizationId: 'org-1',
role: 'owner',
org: {
organizationName: 'Acme',
balance: 0,
requireSeats: false,
seatCount: { used: 1, total: 1 },
},
isResolving: false,
}),
useOrgWithMembers: () => ({
data: { parent_organization_id: null, settings: { minimum_balance: null }, members: [] },
}),
useOrgKiloPassSummary: () => ({ data: undefined, isError: false, refetch: vi.fn() }),
}));

vi.mock('@/lib/hooks/use-theme-colors', () => ({
useThemeColors: () => ({ mutedForeground: '#666666', foreground: '#000000' }),
}));

vi.mock('@/lib/organization-context', () => ({
useOrganization: () => ({ setOrganizationId: vi.fn() }),
}));

async function renderHub(): Promise<TestRenderer.ReactTestRenderer> {
const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined };
await act(async () => {
ref.current = TestRenderer.create(createElement(OrganizationHubScreen));
await Promise.resolve();
});
const renderer = ref.current;
if (!renderer) {
throw new Error('renderer was not created');
}
return renderer;
}

function findRenameControl(root: TestRenderer.ReactTestInstance): TestRenderer.ReactTestInstance {
return root.find(
node =>
String(node.type) === 'Pressable' && node.props.accessibilityLabel === 'Rename organization'
);
}

describe('OrganizationHubScreen rename control', () => {
it('gives the rename control a box at least 28dp on a side and a 44pt tap target', async () => {
const renderer = await renderHub();

expectReliableTapTarget(findRenameControl(renderer.root).props);
});

it('still opens the rename modal from the control', async () => {
const renderer = await renderHub();

act(() => {
(findRenameControl(renderer.root).props.onPress as () => void)();
});

expect(renderer.root.findAll(node => String(node.type) === 'RenameModal')).toHaveLength(1);
});
});
8 changes: 3 additions & 5 deletions apps/mobile/src/components/organization/hub-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { OrgUsageStats } from '@/components/organization/org-usage-stats';
import { RenameModal } from '@/components/rename-modal';
import { ScreenHeader } from '@/components/screen-header';
import { ConfigureRow } from '@/components/ui/configure-row';
import { IconButton } from '@/components/ui/icon-button';
import { KvRow } from '@/components/ui/kv-row';
import { Text } from '@/components/ui/text';
import { TabScreenScrollView } from '@/components/tab-screen';
Expand Down Expand Up @@ -111,17 +112,14 @@ export function OrganizationHubScreen({ organizationIdOverride }: OrganizationHu
{org.organizationName}
</Text>
{showMoney && (
<Pressable
<IconButton
onPress={() => {
setRenameVisible(true);
}}
hitSlop={12}
accessibilityRole="button"
accessibilityLabel={t('organization.hub.renameTitle')}
className="active:opacity-70"
>
<Pencil size={16} color={colors.mutedForeground} />
</Pressable>
</IconButton>
)}
</View>
{showMoney && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { renderWithProviders } from '@/test/render-with-providers';
import { expectReliableTapTarget } from '@/test/touch-target.test-helpers';

import '@/i18n';
import { OrganizationMembersScreen } from './members-screen';
Expand Down Expand Up @@ -96,7 +97,8 @@ vi.mock('@/components/query-error', () => ({
}));

vi.mock('@/components/screen-header', () => ({
ScreenHeader: () => null,
ScreenHeader: (props: { headerRight?: ReactNode }) =>
createElement('ScreenHeader', null, props.headerRight),
}));

vi.mock('@/components/ui/button', () => ({
Expand Down Expand Up @@ -213,3 +215,19 @@ describe('OrganizationMembersScreen empty-state precedence', () => {
expect(texts).not.toContain('EMPTY_STATE:No members yet');
});
});

describe('OrganizationMembersScreen invite control', () => {
// The explorer found the header's "Invite member" control at its bare 22dp
// icon size, below the 28dp minimum. This pins the box that replaced it.
it('sizes the invite control for a reliable tap target', async () => {
const { renderer, unmount } = await renderWithProviders(
createElement(OrganizationMembersScreen)
);
const button = renderer.root.find(
node => String(node.type) === 'Pressable' && node.props.accessibilityLabel === 'Invite member'
);

expectReliableTapTarget(button.props);
unmount();
});
});
Loading
Loading