Skip to content
This repository was archived by the owner on Nov 3, 2025. It is now read-only.

Commit 046bafd

Browse files
committed
✨ (Canal) add Canal and Screen components
1 parent 8c7cf45 commit 046bafd

9 files changed

Lines changed: 407 additions & 1 deletion

File tree

src/Canal.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import React, { Component, ReactElement } from 'react';
2+
import { arrayify } from './utils/Array.arrayify';
3+
import { withBackContext, WithBackContext } from './withBackContext';
4+
import { View, StyleProp, ViewStyle, StyleSheet } from 'react-native';
5+
import { BackEvent } from './Navigation/BackHandlerDelegate';
6+
import { last } from './utils/Array.last';
7+
import { map, publish } from 'rxjs/operators';
8+
import { ConnectableObservable } from 'rxjs';
9+
import { BackContext } from './Navigation/BackContext';
10+
import { Screen, ScreenProps } from './Screen';
11+
12+
interface Props {
13+
children: ReactElement<ScreenProps, typeof Screen>[] | ReactElement<ScreenProps, typeof Screen>;
14+
style?: StyleProp<ViewStyle>;
15+
}
16+
17+
class CanalComponent extends Component<WithBackContext<Props>> {
18+
constructor(props: WithBackContext<Props>) {
19+
super(props);
20+
this.back$.connect();
21+
}
22+
23+
static defaultProps = { style: StyleSheet.absoluteFill };
24+
25+
/**
26+
* @TODO Pipe operator cannot infer return type as ConnectableObservable.
27+
* See https://github.com/ReactiveX/rxjs/issues/2972.
28+
*/
29+
// @ts-ignore
30+
back$: ConnectableObservable<BackEvent> = this.props.backContext.back$.pipe(
31+
map(() => {
32+
const currentScreen = last(
33+
arrayify<ReactElement<ScreenProps, typeof Screen>>(this.props.children).filter(
34+
child => child.props.visible && !child.props.isFullScreen
35+
)
36+
);
37+
if (currentScreen) {
38+
return { target: currentScreen.props.name };
39+
}
40+
return { target: null };
41+
}),
42+
publish()
43+
);
44+
45+
render() {
46+
const { children: reactChildren } = this.props;
47+
const children = arrayify<ReactElement<ScreenProps, typeof Screen>>(reactChildren).filter(
48+
child => !child.props.isFullScreen
49+
);
50+
return (
51+
<BackContext.Provider
52+
value={{
53+
back$: this.back$,
54+
}}
55+
>
56+
<View style={this.props.style}>{children}</View>
57+
</BackContext.Provider>
58+
);
59+
}
60+
}
61+
62+
export const Canal = withBackContext(CanalComponent);

src/Screen.tsx

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import React, { Component as ReactComponent, createFactory, ComponentType } from 'react';
2+
import { BackContext } from './Navigation/BackContext';
3+
import { filter, share, tap } from 'rxjs/operators';
4+
import { Navigation } from './Navigation';
5+
import { TransitionComponentType } from './transitions/Transition';
6+
import { None } from './transitions';
7+
import { withBackContext, WithBackContext } from './withBackContext';
8+
9+
export interface ScreenProps {
10+
isFullScreen?: boolean;
11+
onBack?: (() => any) | undefined;
12+
name: string;
13+
Component: ComponentType<any>;
14+
props?: object | undefined;
15+
Transitioner?: TransitionComponentType;
16+
visible: boolean;
17+
}
18+
19+
class ScreenComponent extends ReactComponent<WithBackContext<ScreenProps>> {
20+
static defaultProps = {
21+
isFullScreen: false,
22+
Transitioner: None,
23+
props: {},
24+
};
25+
26+
back$ = this.props.backContext.back$.pipe(
27+
filter(value => value.target === this.props.name),
28+
tap(() => {
29+
if (this.props.onBack) {
30+
Navigation.instance.backHandlerDelegate.setOnBackCallback(this.props.onBack);
31+
}
32+
}),
33+
share()
34+
);
35+
36+
backSubscription = this.back$.subscribe();
37+
38+
componentWillUnmount() {
39+
this.backSubscription.unsubscribe();
40+
}
41+
42+
/**
43+
* @TODO 2019-07-26 Update @types/react once https://github.com/DefinitelyTyped/DefinitelyTyped/pull is merged.
44+
*/
45+
// @ts-ignore
46+
factory = createFactory(this.props.Component);
47+
48+
render() {
49+
const { Transitioner, onBack, props } = this.props;
50+
return (
51+
/**
52+
* @todo Transitioner is always defined in static defaultProps.
53+
*/
54+
// @ts-ignore
55+
<Transitioner directionForward={this.props.visible}>
56+
<BackContext.Provider
57+
value={{
58+
back$: this.back$,
59+
}}
60+
>
61+
{this.factory({ navigation: { goBack: onBack }, ...props })}
62+
</BackContext.Provider>
63+
</Transitioner>
64+
);
65+
}
66+
}
67+
68+
export const Screen = withBackContext(ScreenComponent);

src/__tests__/Canal.test.tsx

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import React from 'react';
2+
import { Text } from 'react-native';
3+
import TestRenderer from 'react-test-renderer';
4+
import { Canal } from '../Canal';
5+
import { Screen } from '../Screen';
6+
import { Subject } from 'rxjs';
7+
import { BackEvent } from '../Navigation/BackHandlerDelegate';
8+
import { BackContext } from '../Navigation/BackContext';
9+
10+
describe('Canal', () => {
11+
it('renders its children', () => {
12+
const testRenderer = TestRenderer.create(
13+
<Canal>
14+
<Screen Component={() => <Text>a</Text>} name="a" visible />
15+
<Screen Component={() => <Text>b</Text>} name="b" visible />
16+
</Canal>
17+
);
18+
expect(testRenderer.toJSON()).toMatchSnapshot();
19+
});
20+
it('renders its only child', () => {
21+
const testRenderer = TestRenderer.create(
22+
<Canal>
23+
<Screen Component={() => <Text>a</Text>} name="a" visible />
24+
</Canal>
25+
);
26+
expect(testRenderer.toJSON()).toMatchSnapshot();
27+
});
28+
it('renders only non-fullscreen children', () => {
29+
const testRenderer = TestRenderer.create(
30+
<Canal>
31+
<Screen Component={() => <Text>a</Text>} name="a" visible />
32+
<Screen Component={() => <Text>b</Text>} name="b" visible isFullScreen />
33+
</Canal>
34+
);
35+
expect(testRenderer.toJSON()).toMatchSnapshot();
36+
});
37+
it('passes back events on', () => {
38+
const back$ = new Subject<BackEvent>();
39+
const spy = jest.fn();
40+
const testRenderer = TestRenderer.create(
41+
<BackContext.Provider value={{ back$ }}>
42+
<Canal>
43+
<Screen Component={() => <Text>a</Text>} name="a" visible={true} />
44+
<Screen Component={() => <Text>b</Text>} name="b" visible={false} />
45+
</Canal>
46+
</BackContext.Provider>
47+
);
48+
// @ts-ignore
49+
testRenderer.root.children[0].instance.back$.subscribe(spy);
50+
back$.next({ target: null });
51+
expect(spy).toHaveBeenCalledWith({
52+
target: 'a',
53+
});
54+
testRenderer.update(
55+
<BackContext.Provider value={{ back$ }}>
56+
<Canal>
57+
<Screen Component={() => <Text>a</Text>} name="a" visible={true} />
58+
<Screen Component={() => <Text>b</Text>} name="b" visible={true} />
59+
</Canal>
60+
</BackContext.Provider>
61+
);
62+
back$.next({ target: null });
63+
expect(spy).toHaveBeenCalledWith({
64+
target: 'b',
65+
});
66+
testRenderer.update(
67+
<BackContext.Provider value={{ back$ }}>
68+
<Canal>
69+
<Screen Component={() => <Text>a</Text>} name="a" visible={false} />
70+
<Screen Component={() => <Text>b</Text>} name="b" visible={false} />
71+
</Canal>
72+
</BackContext.Provider>
73+
);
74+
back$.next({ target: null });
75+
expect(spy).toHaveBeenCalledWith({
76+
target: null,
77+
});
78+
});
79+
});

src/__tests__/Screen.test.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import React from 'react';
2+
import { Text } from 'react-native';
3+
import TestRenderer from 'react-test-renderer';
4+
import { Screen } from '../Screen';
5+
import { Subject } from 'rxjs';
6+
import { BackEvent } from '../Navigation/BackHandlerDelegate';
7+
import { BackContext } from '../Navigation/BackContext';
8+
import { Navigation } from '../Navigation';
9+
10+
describe('Screen', () => {
11+
it('passes on back events which target itself', () => {
12+
const back$ = new Subject<BackEvent>();
13+
const spy = jest.fn();
14+
const testRenderer = TestRenderer.create(
15+
<BackContext.Provider value={{ back$ }}>
16+
<Screen Component={() => <Text>a</Text>} name="a" visible />
17+
</BackContext.Provider>
18+
);
19+
// @ts-ignore
20+
testRenderer.root.children[0].instance.back$.subscribe(spy);
21+
back$.next({ target: 'a' });
22+
expect(spy).toHaveBeenCalledWith({
23+
target: 'a',
24+
});
25+
});
26+
it('calls onBack callback when back events target itself', () => {
27+
const back$ = new Subject<BackEvent>();
28+
const onBackCallback = jest.fn();
29+
const spy = jest.spyOn(Navigation.instance.backHandlerDelegate, 'setOnBackCallback');
30+
TestRenderer.create(
31+
<BackContext.Provider value={{ back$ }}>
32+
<Screen Component={() => <Text>a</Text>} name="a" visible onBack={onBackCallback} />
33+
</BackContext.Provider>
34+
);
35+
// @ts-ignore
36+
back$.next({ target: 'a' });
37+
expect(spy).toHaveBeenCalledWith(onBackCallback);
38+
});
39+
it('blocks back events which do NOT target itself', () => {
40+
const back$ = new Subject<BackEvent>();
41+
const spy = jest.fn();
42+
const testRenderer = TestRenderer.create(
43+
<BackContext.Provider value={{ back$ }}>
44+
<Screen Component={() => <Text>a</Text>} name="a" visible />
45+
</BackContext.Provider>
46+
);
47+
// @ts-ignore
48+
testRenderer.root.children[0].instance.back$.subscribe(spy);
49+
back$.next({ target: 'b' });
50+
back$.next({ target: null });
51+
back$.next({ target: undefined });
52+
expect(spy).not.toHaveBeenCalled();
53+
});
54+
it('unsubscribe for back events when unmounted', () => {
55+
const back$ = new Subject<BackEvent>();
56+
const testRenderer = TestRenderer.create(
57+
<BackContext.Provider value={{ back$ }}>
58+
<Screen Component={() => <Text>a</Text>} name="a" visible />
59+
</BackContext.Provider>
60+
);
61+
// @ts-ignore
62+
const spy = jest.spyOn(testRenderer.root.children[0].instance.backSubscription, 'unsubscribe');
63+
testRenderer.update(<BackContext.Provider value={{ back$ }}></BackContext.Provider>);
64+
expect(spy).toHaveBeenCalled();
65+
});
66+
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`Canal renders its children 1`] = `
4+
<View
5+
style={
6+
Object {
7+
"bottom": 0,
8+
"left": 0,
9+
"position": "absolute",
10+
"right": 0,
11+
"top": 0,
12+
}
13+
}
14+
>
15+
<View
16+
style={
17+
Object {
18+
"bottom": 0,
19+
"left": 0,
20+
"position": "absolute",
21+
"right": 0,
22+
"top": 0,
23+
}
24+
}
25+
>
26+
<Text>
27+
a
28+
</Text>
29+
</View>
30+
<View
31+
style={
32+
Object {
33+
"bottom": 0,
34+
"left": 0,
35+
"position": "absolute",
36+
"right": 0,
37+
"top": 0,
38+
}
39+
}
40+
>
41+
<Text>
42+
b
43+
</Text>
44+
</View>
45+
</View>
46+
`;
47+
48+
exports[`Canal renders its only child 1`] = `
49+
<View
50+
style={
51+
Object {
52+
"bottom": 0,
53+
"left": 0,
54+
"position": "absolute",
55+
"right": 0,
56+
"top": 0,
57+
}
58+
}
59+
>
60+
<View
61+
style={
62+
Object {
63+
"bottom": 0,
64+
"left": 0,
65+
"position": "absolute",
66+
"right": 0,
67+
"top": 0,
68+
}
69+
}
70+
>
71+
<Text>
72+
a
73+
</Text>
74+
</View>
75+
</View>
76+
`;
77+
78+
exports[`Canal renders only non-fullscreen children 1`] = `
79+
<View
80+
style={
81+
Object {
82+
"bottom": 0,
83+
"left": 0,
84+
"position": "absolute",
85+
"right": 0,
86+
"top": 0,
87+
}
88+
}
89+
>
90+
<View
91+
style={
92+
Object {
93+
"bottom": 0,
94+
"left": 0,
95+
"position": "absolute",
96+
"right": 0,
97+
"top": 0,
98+
}
99+
}
100+
>
101+
<Text>
102+
a
103+
</Text>
104+
</View>
105+
</View>
106+
`;

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
export { createCanal } from './createCanal';
22
export { FullScreenPortal } from './FullScreenPortal';
3+
export { Screen } from './Screen';
4+
export { Canal } from './Canal';

0 commit comments

Comments
 (0)