Skip to content

Commit 5f0f6a0

Browse files
Fix clipped bottom tab labels (#124)
* chore: align Expo SDK patch versions * fix: prevent bottom tab label clipping * test: cover responsive tab bar wiring --------- Co-authored-by: Owen McGirr <o.a.mcgirr@gmail.com>
1 parent 2eb7dcc commit 5f0f6a0

8 files changed

Lines changed: 258 additions & 142 deletions

File tree

docs/accessibility.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Switchify Remote is designed for VoiceOver, TalkBack, iOS Switch Control, and An
1717
- Content remains operable in portrait, landscape, and tablet widths, with scrolling available at large text sizes.
1818
- The palette maintains at least 4.5:1 contrast for normal text. Color is never the only status cue.
1919
- Light and dark appearance follow the device setting. Both palettes maintain the same contrast and state-cue requirements.
20-
- The primary tab bar contains PCs, Remote, and Settings. Diagnostics is available from Settings and uses a standard back action. Settings also exposes the Remote privacy policy as one clearly named browser action.
20+
- The primary tab bar contains PCs, Remote, and Settings. Its height follows text scaling and the platform safe area so every label remains complete. Diagnostics is available from Settings and uses a standard back action. Settings also exposes the Remote privacy policy as one clearly named browser action.
2121
- Settings exposes a Remote name text field with complete validation and synchronization status. Save and Use device model remain separate 48-point controls at large text sizes.
2222
- Selected controls combine color with a check icon and selected accessibility state.
2323
- Repeating pointer movement exposes a dedicated Stop movement button alongside the existing stop-on-control behavior. Both send the PC an acknowledged stop command before Android switch-capture cleanup.

docs/physical-smoke-test.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Run the matrix on a physical Android phone and iPhone against current Switchify
1919
7. Exercise held modifiers, shortcuts, app switching, task view, desktop, minimize, maximize, and close. Confirm labels follow Windows/macOS conventions.
2020
- On Android, open Forwarding and confirm profile choices remain available while stopped. Confirm mapped switches and the overflow notice appear only after forwarding starts and disappear after manual, hold-to-stop, configuration-change, and inactivity stops.
2121
8. With VoiceOver/TalkBack and Switch Control/Switch Access, traverse every destination at maximum text size in portrait and landscape. Confirm selected, disabled, busy, error, and pairing states are announced.
22-
- Test a 320–428 point phone at 100%, 150%, and 200% text in light and dark appearance. Confirm headers stack, labels remain complete, action grids reduce columns, and the last control scrolls clear of the tab bar and system gesture area.
22+
- Test a 320–428 point phone at 100%, 150%, and 200% text in light and dark appearance. Confirm headers stack, labels remain complete, action grids reduce columns, and the last control scrolls clear of the tab bar and system gesture area. On Android, repeat with gesture and three-button navigation and confirm PCs, Remote, and Settings are never clipped. On iOS, repeat with the home indicator visible.
2323
- Rename a paired PC to a long fixture name. Confirm the Remote status, PC card, and Unpair control wrap without truncation.
2424
- 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.
2525
- 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.

package-lock.json

Lines changed: 135 additions & 130 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,28 @@
33
"main": "expo-router/entry",
44
"version": "1.0.0-beta.17",
55
"dependencies": {
6-
"@expo/ui": "~57.0.14",
6+
"@expo/ui": "~57.0.16",
77
"@expo/vector-icons": "^15.0.2",
88
"@noble/hashes": "^2.3.0",
99
"@react-native-async-storage/async-storage": "2.2.0",
1010
"@react-navigation/native": "^7.3.16",
1111
"base64-js": "^1.5.1",
12-
"expo": "~57.0.18",
12+
"expo": "~57.0.20",
1313
"expo-blur": "~57.0.2",
14-
"expo-build-properties": "~57.0.15",
14+
"expo-build-properties": "~57.0.17",
1515
"expo-clipboard": "~57.0.1",
1616
"expo-constants": "~57.0.16",
1717
"expo-crypto": "~57.0.2",
18-
"expo-dev-client": "~57.0.16",
18+
"expo-dev-client": "~57.0.18",
1919
"expo-device": "~57.0.1",
2020
"expo-file-system": "~57.0.6",
2121
"expo-font": "~57.0.2",
2222
"expo-glass-effect": "~57.0.1",
23-
"expo-image": "~57.0.3",
24-
"expo-linking": "~57.0.8",
25-
"expo-router": "~57.0.17",
26-
"expo-secure-store": "~57.0.2",
27-
"expo-sharing": "~57.0.16",
23+
"expo-image": "~57.0.4",
24+
"expo-linking": "~57.0.9",
25+
"expo-router": "~57.0.19",
26+
"expo-secure-store": "~57.0.3",
27+
"expo-sharing": "~57.0.18",
2828
"expo-splash-screen": "~57.0.8",
2929
"expo-status-bar": "~57.0.1",
3030
"expo-symbols": "~57.0.2",

src/app/(tabs)/_layout.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,26 @@
11
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
22
import { Tabs } from 'expo-router';
3+
import { useWindowDimensions } from 'react-native';
4+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
35

6+
import { computeBottomTabBarHeight } from '@/navigation/bottomTabBarLayout';
47
import { tabDefinitions } from '@/navigation/tabDefinitions';
58
import { useTheme } from '@/theme/ThemeContext';
69

710
const icons = { index: 'computer', remote: 'settings-remote', settings: 'settings' } as const;
811

912
export default function TabsLayout() {
1013
const { colors, scheme } = useTheme();
14+
const { fontScale } = useWindowDimensions();
15+
const { bottom } = useSafeAreaInsets();
16+
const tabBarHeight = computeBottomTabBarHeight(fontScale, bottom);
17+
1118
return (
1219
<Tabs screenOptions={({ route }) => ({
1320
headerShown: false,
1421
tabBarActiveTintColor: scheme === 'dark' ? colors.brandText : colors.brand,
1522
tabBarInactiveTintColor: colors.textMuted,
16-
tabBarStyle: { backgroundColor: colors.surface, borderTopColor: colors.border, minHeight: 64 },
23+
tabBarStyle: { backgroundColor: colors.surface, borderTopColor: colors.border, height: tabBarHeight },
1724
tabBarIcon: ({ color, size }) => <MaterialIcons color={color} name={icons[route.name as keyof typeof icons]} size={size} />,
1825
})}>
1926
{tabDefinitions.map((tab) => <Tabs.Screen key={tab.name} name={tab.name} options={{ title: tab.title }} />)}

src/navigation/TabsLayout.test.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { render } from '@testing-library/react-native';
2+
import { useWindowDimensions } from 'react-native';
3+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
4+
5+
import TabsLayout from '@/app/(tabs)/_layout';
6+
7+
jest.mock('react-native/Libraries/Utilities/useWindowDimensions', () => ({ __esModule: true, default: jest.fn() }));
8+
jest.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: jest.fn() }));
9+
jest.mock('@expo/vector-icons/MaterialIcons', () => () => null);
10+
jest.mock('@/theme/ThemeContext', () => ({
11+
useTheme: () => ({
12+
scheme: 'light',
13+
colors: { brand: '#D90429', brandText: '#FFFFFF', border: '#CCCCCC', surface: '#FFFFFF', textMuted: '#555555' },
14+
}),
15+
}));
16+
jest.mock('expo-router', () => {
17+
const React = jest.requireActual<typeof import('react')>('react');
18+
const { View } = jest.requireActual<typeof import('react-native')>('react-native');
19+
20+
function Tabs({ screenOptions, children }: { screenOptions: (input: { route: { name: string } }) => object; children: React.ReactNode }) {
21+
const resolvedOptions = screenOptions({ route: { name: 'remote' } });
22+
return React.createElement(View, { testID: 'tabs', accessibilityValue: { text: JSON.stringify(resolvedOptions) } }, children);
23+
}
24+
25+
function TabsScreen({ name, options }: { name: string; options: { title: string } }) {
26+
return React.createElement(View, { testID: `tab-${name}`, accessibilityLabel: options.title });
27+
}
28+
29+
Tabs.Screen = TabsScreen;
30+
31+
return { Tabs };
32+
});
33+
34+
const mockWindowDimensions = useWindowDimensions as jest.MockedFunction<typeof useWindowDimensions>;
35+
const mockSafeAreaInsets = useSafeAreaInsets as jest.MockedFunction<typeof useSafeAreaInsets>;
36+
37+
describe('TabsLayout', () => {
38+
beforeEach(() => {
39+
mockWindowDimensions.mockReturnValue({ width: 390, height: 844, scale: 3, fontScale: 1 });
40+
mockSafeAreaInsets.mockReturnValue({ top: 0, right: 0, bottom: 24, left: 0 });
41+
});
42+
43+
it('applies font scale and the bottom inset to the explicit tab bar height', async () => {
44+
const view = await render(<TabsLayout />);
45+
expect(JSON.parse(view.getByTestId('tabs').props.accessibilityValue.text).tabBarStyle.height).toBe(88);
46+
47+
mockWindowDimensions.mockReturnValue({ width: 844, height: 390, scale: 3, fontScale: 2 });
48+
mockSafeAreaInsets.mockReturnValue({ top: 0, right: 0, bottom: 34, left: 0 });
49+
await view.rerender(<TabsLayout />);
50+
51+
expect(JSON.parse(view.getByTestId('tabs').props.accessibilityValue.text).tabBarStyle.height).toBe(106);
52+
});
53+
54+
it('renders the three primary destinations with unchanged labels', async () => {
55+
const view = await render(<TabsLayout />);
56+
expect(view.getByTestId('tab-index').props.accessibilityLabel).toBe('PCs');
57+
expect(view.getByTestId('tab-remote').props.accessibilityLabel).toBe('Remote');
58+
expect(view.getByTestId('tab-settings').props.accessibilityLabel).toBe('Settings');
59+
});
60+
});
61+
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { computeBottomTabBarHeight, MIN_TAB_BAR_CONTENT_HEIGHT } from './bottomTabBarLayout';
2+
3+
describe('computeBottomTabBarHeight', () => {
4+
it.each([
5+
[1, 0, 64],
6+
[1.5, 0, 64],
7+
[2, 0, 72],
8+
[3, 0, 88],
9+
[2, 24, 96],
10+
])('uses font scale %s and bottom inset %s to produce %s points', (fontScale, bottomInset, expected) => {
11+
expect(computeBottomTabBarHeight(fontScale, bottomInset)).toBe(expected);
12+
});
13+
14+
it('keeps the minimum content height below 100% text scaling', () => {
15+
expect(computeBottomTabBarHeight(0.8, 0)).toBe(MIN_TAB_BAR_CONTENT_HEIGHT);
16+
});
17+
18+
it('falls back safely for invalid measurements', () => {
19+
expect(computeBottomTabBarHeight(Number.NaN, Number.NaN)).toBe(MIN_TAB_BAR_CONTENT_HEIGHT);
20+
expect(computeBottomTabBarHeight(Number.POSITIVE_INFINITY, -10)).toBe(MIN_TAB_BAR_CONTENT_HEIGHT);
21+
});
22+
});
23+
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export const MIN_TAB_BAR_CONTENT_HEIGHT = 64;
2+
3+
const FIXED_TAB_BAR_CONTENT_HEIGHT = 40;
4+
const TAB_LABEL_LINE_HEIGHT = 16;
5+
6+
function finiteAtLeast(value: number, minimum: number): number {
7+
return Number.isFinite(value) ? Math.max(minimum, value) : minimum;
8+
}
9+
10+
export function computeBottomTabBarHeight(fontScale: number, bottomInset: number): number {
11+
const scale = finiteAtLeast(fontScale, 1);
12+
const inset = finiteAtLeast(bottomInset, 0);
13+
const contentHeight = Math.max(
14+
MIN_TAB_BAR_CONTENT_HEIGHT,
15+
Math.ceil(FIXED_TAB_BAR_CONTENT_HEIGHT + TAB_LABEL_LINE_HEIGHT * scale),
16+
);
17+
18+
return contentHeight + inset;
19+
}
20+

0 commit comments

Comments
 (0)