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
14 changes: 14 additions & 0 deletions apps/src/tests/single-feature-tests/contained-modal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { ScenarioGroup } from '@apps/tests/shared/helpers';
import TestContainedModalBase from './test-contained-modal-base-ios';

const scenarios = {
TestContainedModalBase,
};

const ContainedModalScenarioGroup: ScenarioGroup<keyof typeof scenarios> = {
name: 'ContainedModal',
details: 'Single feature tests for ContainedModals',

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'ContainedModals' makes more sense, especially when we add a different style (CurrentContext) in the future

scenarios,
};

export default ContainedModalScenarioGroup;
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import React, { useState } from 'react';
import { Button, Pressable, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import {
ContainedModal,
ContainedModalProvider,
} from 'react-native-screens/experimental';
import { scenarioDescription } from './scenario-description';
import { createScenario } from '@apps/tests/shared/helpers';
import { Colors } from '@apps/shared/styling';

const CONTAINER_ID = 'contained-modal-base';

export function App() {
const insets = useSafeAreaInsets();
const [isOpen, setIsOpen] = useState(false);
const [partialProvider, setPartialProvider] = useState(false);
const [insideCount, setInsideCount] = useState(0);
const [outsideCount, setOutsideCount] = useState(0);

return (
<View style={[styles.screen, { paddingTop: insets.top }]}>
<View style={styles.content}>
<Text style={styles.title}>ContainedModal Test</Text>
<Button
title={
partialProvider
? 'Provider: partial (tap for full screen)'
: 'Provider: full screen (tap for partial)'
}
color={Colors.primary}
onPress={() => setPartialProvider(value => !value)}
/>
<View style={styles.spacing} />
<Button
title="Open contained modal"
color={Colors.primary}
onPress={() => setIsOpen(true)}
/>
<View style={styles.spacing} />
<Pressable
style={styles.counter}
onPress={() => setOutsideCount(value => value + 1)}>
<Text style={styles.counterText}>Outside count: {outsideCount}</Text>
</Pressable>
</View>

<ContainedModalProvider
containerId={CONTAINER_ID}
style={partialProvider ? styles.partialProvider : styles.fullProvider}>
<View style={styles.providerContent}>
<Pressable
style={styles.counter}
onPress={() => setInsideCount(value => value + 1)}>
<Text style={styles.counterText}>Inside count: {insideCount}</Text>
</Pressable>
</View>
<ContainedModal targetContainerId={CONTAINER_ID} isOpen={isOpen}>
<View style={styles.modal}>
<Text style={styles.sheetTitle}>🎉 This is a contained modal</Text>
<View style={styles.spacing} />
<Button
title="Close"
color={Colors.primary}
onPress={() => setIsOpen(false)}
/>
</View>
</ContainedModal>
</ContainedModalProvider>
</View>
);
}

const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: Colors.offBackground,
},
content: {
paddingTop: 16,
alignItems: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
marginBottom: 20,
color: Colors.text,
},
fullProvider: {
flex: 1,
marginTop: 16,
borderWidth: 2,
borderColor: 'transparent',
},
partialProvider: {
height: 320,
marginTop: 16,
marginHorizontal: 24,
marginBottom: 24,
borderWidth: 2,
borderColor: Colors.primary,
borderRadius: 12,
overflow: 'hidden',
},
providerContent: {
padding: 16,
alignItems: 'center',
},
counter: {
paddingVertical: 12,
paddingHorizontal: 20,
borderRadius: 8,
borderWidth: 2,
borderColor: Colors.primary,
backgroundColor: Colors.background,
alignItems: 'center',
},
counterText: {
fontSize: 16,
fontWeight: '600',
color: Colors.primary,
},
modal: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 24,
backgroundColor: 'transparent',
},
sheetTitle: {
fontSize: 22,
fontWeight: '600',
marginBottom: 12,
color: Colors.text,
},
spacing: {
height: 32,
},
});

export default createScenario(App, 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: 'Basic functionality',
key: 'test-contained-modal-base-ios',
details:
'Allows testing the basic functionality of the ContainedModal component.',
platforms: ['ios'],
e2eCoverage: 'tbd',
smokeTest: false,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Test Scenario: Basic functionality

## Details

**Description:** Verify the core functionality of the `ContainedModal` component together with `ContainedModalProvider`. This test ensures that the modal is presented within the bounds of the provider it targets (matched by `containerId` / `targetContainerId`), that it works both with a full-screen and a partial-size provider, that its transparent background lets the provider's content show through (over-current-context presentation), and that presenting/dismissing repeatedly keeps working.

**OS test creation version:** iOS: 18.6 and 26.4, iPadOS 26.4

## E2E test

TBD.

## Prerequisites

- iOS device or simulator: iPhone and iPad
- `ContainedModal` is currently an iOS-only component.

## Note

- The modal is presented from the provider whose `containerId` equals the modal's `targetContainerId`, so it is contained within that provider's bounds, not presented over the whole window.
- The modal's content has a transparent background, so the provider's content (the "Inside count" button) stays visible behind it.
- Two counters exercise touch routing:
- **"Outside count"** lives outside the provider (in the top section) and is never covered by the modal.
- **"Inside count"** lives inside the provider, directly underneath where the modal is presented.

## Touch-routing invariant (applies in every step below)

Regardless of whether the provider is full-screen or partial:

- **"Outside count" must always be tappable** - before, while, and after a modal is presented. It sits outside the provider, so the modal must never block it.
- **"Inside count" must be blocked _only while the modal is open_.** While the modal is presented it sits underneath the (transparent) modal content and must not receive taps. As soon as the modal is dismissed it must be tappable again.

## Steps

### Baseline

1. Launch the app and navigate to the **Basic functionality** screen.

- [ ] Expected: Content is shown with the "Provider: full screen (tap for partial)" button, an "Open contained modal" button, an "Outside count: 0" button, and (inside the provider region) an "Inside count: 0" button.

---

### Background pressables work (no modal presented)

2. Tap "Outside count" and "Inside count" a few times each.

- [ ] Expected: Both counters increment on every tap (all of the provider's content and the surrounding content are interactive while no modal is presented).

---

### Present within a full-screen provider

3. Tap "Open contained modal".

- [ ] Expected: The modal appears with a cross-dissolve (fade-in) animation. The "🎉 This is a contained modal" text and the "Close" button are centered. Because the modal background is transparent, the underlying "Inside count" button is still visible behind it.

---

### Touch routing while the modal is open (full-screen provider)

4. With the modal still open, tap where the "Inside count" button is (underneath the modal), then tap "Outside count".

- [ ] Expected: "Inside count" does **not** increment (it is covered by the open modal). "Outside count" **does** increment (it is outside the provider and stays interactive). See the touch-routing invariant above.

---

### Dismiss

5. Tap "Close".

- [ ] Expected: The modal dismisses smoothly.

---

### Background pressable works again after dismiss (touch-handler cleanup)

> Regression check: after dismissal, the modal's touch handler must be detached.
> If it is not, it keeps consuming touches in the area the modal used to occupy and
> silently swallows taps meant for the "Inside count" button behind it, even though
> the modal is no longer visible.

6. Note the current "Inside count" value, then tap the "Inside count" button (the one
that sits in the region the modal was just covering) several times. Tap "Outside
count" too.

- [ ] Expected: Both counters increment on every tap. Taps on "Inside count" are NOT
swallowed - there is no dead/unresponsive zone left behind where the dismissed
modal used to be.

7. Re-open the modal ("Open contained modal"), tap "Close" again, and repeat the taps
from step 6.

- [ ] Expected: Both counters still increment on every tap after this second
open/close cycle (the touch handler is correctly re-attached on re-present and
detached again on dismiss).

---

### Present within a partial provider

8. Tap "Provider: full screen (tap for partial)" to switch to the partial provider, then tap "Open contained modal".

- [ ] Expected: A bordered region (the provider bounds) is visible. The modal is presented **within that bordered region only**, not over the whole screen. The modal content is sized and positioned within the provider's bounds.

---

### Touch routing while the modal is open (partial provider)

9. With the modal still open, tap where the "Inside count" button is (underneath the modal, inside the bordered region), then tap "Outside count".

- [ ] Expected: Same invariant as the full-screen case - "Inside count" does **not** increment while the modal is open, "Outside count" **does**. The smaller provider size must not change this behavior.

---

### Dismiss from partial provider

10. Tap "Close".

- [ ] Expected: The modal dismisses smoothly and the screen returns to the partial-provider layout. "Inside count" is tappable again and "Outside count" keeps working.
2 changes: 2 additions & 0 deletions apps/src/tests/single-feature-tests/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import StackV5ScenarioGroup from './stack-v5';
import StackV4ScenarioGroup from './stack-v4';
import ScrollViewMarkerScenarioGroup from './scroll-view-marker';
import FormSheetScenarioGroup from './form-sheet';
import ContainedModalScenarioGroup from './contained-modal';
import { ScenarioButton } from '@apps/tests/shared/ScenarioButton';
import ScenarioSelectionScreen from '@apps/tests/shared/ScenarioScreen';

Expand All @@ -22,6 +23,7 @@ export const COMPONENT_SCENARIOS = {
StackV4: StackV4ScenarioGroup,
ScrollViewMarker: ScrollViewMarkerScenarioGroup,
FormSheet: FormSheetScenarioGroup,
ContainedModal: ContainedModalScenarioGroup,
} as const;

type ParamsList = { [k: keyof typeof COMPONENT_SCENARIOS]: undefined } & {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#pragma once

#if !defined(ANDROID)

#include <react/debug/react_native_assert.h>
#include <react/renderer/core/ConcreteComponentDescriptor.h>
#include "RNSContainedModalHostShadowNode.h"

namespace facebook::react {

class RNSContainedModalHostComponentDescriptor final
: public ConcreteComponentDescriptor<RNSContainedModalHostShadowNode> {
public:
using ConcreteComponentDescriptor::ConcreteComponentDescriptor;

void adopt(ShadowNode &shadowNode) const override {
react_native_assert(
dynamic_cast<RNSContainedModalHostShadowNode *>(&shadowNode));
auto &concreteShadowNode =
static_cast<RNSContainedModalHostShadowNode &>(shadowNode);

react_native_assert(
dynamic_cast<YogaLayoutableShadowNode *>(&concreteShadowNode));
auto &layoutableShadowNode =
static_cast<YogaLayoutableShadowNode &>(concreteShadowNode);

auto state = std::static_pointer_cast<
const RNSContainedModalHostShadowNode::ConcreteState>(
shadowNode.getState());

auto stateData = state->getData();

if (stateData.frameSize.width > 0 && stateData.frameSize.height > 0) {
layoutableShadowNode.setSize(
Size{stateData.frameSize.width, stateData.frameSize.height});
}

ConcreteComponentDescriptor::adopt(shadowNode);
}
};

} // namespace facebook::react

#endif // !defined(ANDROID)
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#include "RNSContainedModalHostShadowNode.h"

#if !defined(ANDROID)

namespace facebook::react {

extern const char RNSContainedModalHostComponentName[] =
"RNSContainedModalHost";

Point RNSContainedModalHostShadowNode::getContentOriginOffset(
bool /*includeTransform*/) const {
return getStateData().contentOffset;
}

} // namespace facebook::react

#endif // !defined(ANDROID)
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#pragma once

#if !defined(ANDROID)

#include <jsi/jsi.h>
#include <react/renderer/components/rnscreens/EventEmitters.h>
#include <react/renderer/components/rnscreens/Props.h>
#include <react/renderer/components/view/ConcreteViewShadowNode.h>
#include "RNSContainedModalHostState.h"

namespace facebook::react {

JSI_EXPORT extern const char RNSContainedModalHostComponentName[];

class JSI_EXPORT RNSContainedModalHostShadowNode final
: public ConcreteViewShadowNode<
RNSContainedModalHostComponentName,
RNSContainedModalHostProps,
RNSContainedModalHostEventEmitter,
RNSContainedModalHostState> {
public:
using ConcreteViewShadowNode::ConcreteViewShadowNode;

Point getContentOriginOffset(bool includeTransform) const override;
};

} // namespace facebook::react

#endif // !defined(ANDROID)
Loading
Loading