Skip to content
Open
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
Expand Up @@ -31,6 +31,14 @@ const tabBarButtonType = isIOSVersionAtLeast('26.0')
? CLASS_NAME_UI_TAB_BAR_BUTTON_IOS26
: CLASS_NAME_UI_TAB_BAR_BUTTON_LEGACY;

// Throughout iOS 26.x UIKit gives a tab whose `UITabBarItem` uses the `search` system item the
// separate platter view and a runtime switch to/from `search` moves the item. Starting with
// iOS 27.0 a plain search item stays inline — the separation is tied to `UISearchTab`
// (the `searchRole` prop) with `automaticallyActivatesSearch` on, covered by the separate
// `test-tabs-search-tab-activation-ios` scenario.
const shouldMoveTabBarItemOnRuntimeSearchSwitch =
isIOSVersionAtLeast('26.0') && !isIOSVersionAtLeast('27.0');

describeIfIOS('Tab Bar System Item', () => {
beforeAll(async () => {
await device.reloadReactNative();
Expand Down Expand Up @@ -151,7 +159,7 @@ describeIfIOS('Tab Bar System Item', () => {

const frameXAfterSearch = await getTabBarItemFrameX('Search');

if (isIOSVersionAtLeast(`26.0`)) {
if (shouldMoveTabBarItemOnRuntimeSearchSwitch) {
jestExpect(frameXAfterSearch).toBeGreaterThan(frameXBeforeSearch);
} else {
jestExpect(frameXAfterSearch).toEqual(frameXBeforeSearch);
Expand All @@ -177,7 +185,7 @@ describeIfIOS('Tab Bar System Item', () => {

const frameXAfterFavorites = await getTabBarItemFrameX('Favorites');

if (isIOSVersionAtLeast(`26.0`)) {
if (shouldMoveTabBarItemOnRuntimeSearchSwitch) {
jestExpect(frameXAfterFavorites).toBeLessThan(frameXBeforeFavorites);
} else {
jestExpect(frameXAfterFavorites).toEqual(frameXBeforeFavorites);
Expand Down Expand Up @@ -366,7 +374,7 @@ describeIfIOS('Tab Bar System Item', () => {

const frameXAfterSearch = await getTabBarItemFrameX('Custom');

if (isIOSVersionAtLeast(`26.0`)) {
if (shouldMoveTabBarItemOnRuntimeSearchSwitch) {
jestExpect(frameXAfterSearch).toBeGreaterThan(frameXBeforeSearch);
} else {
jestExpect(frameXAfterSearch).toEqual(frameXBeforeSearch);
Expand Down Expand Up @@ -429,7 +437,7 @@ describeIfIOS('Tab Bar System Item', () => {

const frameXAfterHistory = await getTabBarItemFrameX('Custom');

if (isIOSVersionAtLeast(`26.0`)) {
if (shouldMoveTabBarItemOnRuntimeSearchSwitch) {
jestExpect(frameXAfterHistory).toBeLessThan(frameXBeforeHistory);
} else {
jestExpect(frameXAfterHistory).toEqual(frameXBeforeHistory);
Expand Down
3 changes: 3 additions & 0 deletions apps/src/tests/component-integration-tests/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,23 @@ import OrientationScenarioGroup from './orientation';
import ScrollViewScenarioGroup from './scroll-view';
import FormSheetScenarioGroup from './form-sheet';
import TabsInStackV5ScenarioGroup from './tabs-stack-v5';
import TabsStackV4ScenarioGroup from './tabs-stack-v4';
import SvmScenarioGroup from './scroll-view-marker';
import ScenarioSelectionScreen from '@apps/tests/shared/ScenarioScreen';

export * from './orientation';
export * from './scroll-view';
export * from './form-sheet';
export * from './tabs-stack-v5';
export * from './tabs-stack-v4';
export * from './scroll-view-marker';

export const COMPONENT_SCENARIOS = {
Orientation: OrientationScenarioGroup,
ScrollView: ScrollViewScenarioGroup,
FormSheet: FormSheetScenarioGroup,
TabsInStackV5: TabsInStackV5ScenarioGroup,
TabsStackV4: TabsStackV4ScenarioGroup,
ScrollViewMarker: SvmScenarioGroup,
} as const;

Expand Down
16 changes: 16 additions & 0 deletions apps/src/tests/component-integration-tests/tabs-stack-v4/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { ScenarioGroup } from '@apps/tests/shared/helpers';
import TestTabsSearchTabActivation from './test-tabs-search-tab-activation-ios';

export { default as TestTabsSearchTabActivation } from './test-tabs-search-tab-activation-ios';

const scenarios = {
TestTabsSearchTabActivation,
};

const TabsStackV4ScenarioGroup: ScenarioGroup<keyof typeof scenarios> = {
name: 'Stack V4 & Native Tabs Integration Tests',
details: 'Test interaction between TabsContainer and the legacy (v4) stack',
scenarios,
};

export default TabsStackV4ScenarioGroup;
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
import React, {
createContext,
useContext,
useLayoutEffect,
useState,
} from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import {
NavigationContainer,
NavigationIndependentTree,
ParamListBase,
type RouteProp,
} from '@react-navigation/native';
import {
createNativeStackNavigator,
NativeStackNavigationProp,
} from '@react-navigation/native-stack';
import { ListItem, SettingsSwitch } from '@apps/shared';
import {
TabsContainer,
useTabsNavigationContext,
type TabRouteConfig,
} from '@apps/shared/containers/tabs';
import { Colors } from '@apps/shared/styling';
import { scenarioDescription } from './scenario-description';
import { createScenario } from '@apps/tests/shared/helpers';

// The search tab hosts a legacy (v4) native stack - v5 stack does not integrate
// the search bar into the header yet, and UISearchTab activation on iOS 26.1+
// works with the search controller mirrored from the nested stack's header.

type SearchStackParamList = {
List: undefined;
Details: { place: string };
};

type SearchStackNavigationProp = NativeStackNavigationProp<
SearchStackParamList & ParamListBase
>;

const SearchStack = createNativeStackNavigator<SearchStackParamList>();

// The toggle lives on the Config tab, while the option applies to the search
// bar of the search stack's root screen - shared through context.
// UIKit defaults hidesNavigationBarDuringPresentation to true.
const HideNavigationBarContext = createContext<{
hideNavigationBar: boolean;
setHideNavigationBar: (value: boolean) => void;
}>({
hideNavigationBar: true,
setHideNavigationBar: () => {},
});

const PLACES = [
'🏝️ Desert Island',
'🏞️ National Park',
'⛰️ Mountain',
'🏰 Castle',
'🗽 Statue of Liberty',
'🌉 Bridge at Night',
'🏛️ Classical Building',
'🏟️ Stadium',
'🏫 School',
'⛲ Fountain',
'🌄 Sunrise Over Mountains',
'🎡 Ferris Wheel',
];

function SearchListScreen({
navigation,
}: {
navigation: SearchStackNavigationProp;
}) {
const [search, setSearch] = useState('');
const { hideNavigationBar } = useContext(HideNavigationBarContext);

useLayoutEffect(() => {
navigation.setOptions({
headerSearchBarOptions: {
placeholder: 'Search places',
hideNavigationBar,
onChangeText: event => setSearch(event.nativeEvent.text),
},
});
}, [navigation, hideNavigationBar]);

return (
<ScrollView
contentInsetAdjustmentBehavior="automatic"
keyboardDismissMode="on-drag">
{PLACES.filter(
item => item.toLowerCase().indexOf(search.toLowerCase()) !== -1,
).map(place => (
<ListItem
key={place}
title={place}
onPress={() => navigation.push('Details', { place })}
/>
))}
</ScrollView>
);
}

function SearchDetailsScreen({
navigation,
route,
}: {
navigation: SearchStackNavigationProp;
route: RouteProp<SearchStackParamList, 'Details'>;
}) {
const [search, setSearch] = useState('');

// This screen mounts its own search bar. UIKit hosts (and auto-activates)
// only the search bar of the ROOT screen of the search stack - this one is
// a regular in-header search bar, activated by tapping it.
useLayoutEffect(() => {
navigation.setOptions({
headerSearchBarOptions: {
placeholder: 'Search in details',
onChangeText: event => setSearch(event.nativeEvent.text),
},
});
}, [navigation]);

return (
<View style={styles.screen}>
<Text style={styles.label}>{route.params.place}</Text>
<Text style={styles.hint}>
Typed in the Details search bar: &quot;{search}&quot;
</Text>
<Text style={styles.hint}>
With this screen on top the stack is not at its root,{'\n'}so UIKit
removes the tab-hosted search field: switching{'\n'}to Config and
reselecting the search tab must NOT{'\n'}activate any search. This
screen&apos;s bar activates by{'\n'}tapping it in the header.
{'\n'}
{'\n'}
Go back with the back gesture - the &quot;Search places&quot;
{'\n'}field returns to the tab bar, and reselecting the{'\n'}search tab
auto-activates it again.
</Text>
</View>
);
}

function SearchStackTab() {
return (
<SearchStack.Navigator>
<SearchStack.Screen
name="List"
component={SearchListScreen}
options={{ headerLargeTitle: true }}
/>
<SearchStack.Screen name="Details" component={SearchDetailsScreen} />
</SearchStack.Navigator>
);
}

function ConfigTab() {
const { setRouteOptions } = useTabsNavigationContext();
const { hideNavigationBar, setHideNavigationBar } = useContext(
HideNavigationBarContext,
);
const [automaticallyActivatesSearch, setAutomaticallyActivatesSearch] =
useState(false);

const onAutomaticallyActivatesSearchChange = (value: boolean) => {
setAutomaticallyActivatesSearch(value);
// Options set at mount are snapshotted by the container - runtime changes
// must go through setRouteOptions. `ios` is replaced wholesale, so repeat
// the full search tab config here.
setRouteOptions('Search', {
ios: {
systemItem: 'search',
searchRole: true,
automaticallyActivatesSearch: value,
},
});
};

return (
<View style={styles.screen}>
<Text style={styles.label}>Search Tab Activation</Text>
<SettingsSwitch
label="automaticallyActivatesSearch"
value={automaticallyActivatesSearch}
onValueChange={onAutomaticallyActivatesSearchChange}
/>
<SettingsSwitch
label="hidesNavigationBarDuringPresentation"
value={hideNavigationBar}
onValueChange={setHideNavigationBar}
/>
<Text style={styles.hint}>
iOS 26.1+: with the switch on, selecting the search tab{'\n'}should
immediately activate the search field; cancelling{'\n'}the search should
restore this tab.
{'\n'}
{'\n'}
With the switch off, the search tab behaves like{'\n'}a regular tab.
</Text>
</View>
);
}

const ROUTE_CONFIGS: TabRouteConfig[] = [
{
name: 'Config',
element: <ConfigTab />,
options: {
title: 'Config',
tabBarItemTestID: 'config-tab-item',
ios: {
icon: { type: 'sfSymbol', name: 'gear' },
},
},
},
{
name: 'Search',
element: <SearchStackTab />,
options: {
tabBarItemTestID: 'search-tab-item',
ios: {
systemItem: 'search',
searchRole: true,
},
},
},
];

function TestTabsSearchTabActivation() {
const [hideNavigationBar, setHideNavigationBar] = useState(true);

return (
<HideNavigationBarContext.Provider
value={{ hideNavigationBar, setHideNavigationBar }}>
<NavigationIndependentTree>
<NavigationContainer>
<TabsContainer routeConfigs={ROUTE_CONFIGS} />
</NavigationContainer>
</NavigationIndependentTree>
</HideNavigationBarContext.Provider>
);
}

const styles = StyleSheet.create({
screen: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 24,
gap: 12,
},
label: {
fontSize: 17,
fontWeight: '600',
textAlign: 'center',
},
hint: {
fontSize: 13,
color: Colors.LightOffNavy,
textAlign: 'center',
lineHeight: 20,
},
});

export default createScenario(TestTabsSearchTabActivation, scenarioDescription);
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { ScenarioDescription } from '@apps/tests/shared/helpers';

export const scenarioDescription: ScenarioDescription = {
name: 'Search Tab Activation',
key: 'test-tabs-search-tab-activation-ios',
details:
'Validates automaticallyActivatesSearch on a searchRole tab with a search bar in the nested (legacy) stack header, including the UIKit hosted-search contract across push/pop (hosted field exists only at the stack root).',
platforms: ['ios'],
e2eCoverage: 'tbd',
smokeTest: false,
};
Loading