@react-navigation/lynx 0.3.0 → 0.4.0-canary-20260909-fa1c06ba

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@react-navigation/lynx",
3
- "version": "0.3.0",
3
+ "version": "0.4.0-canary-20260909-fa1c06ba",
4
4
  "description": "Lynx integration for React Navigation",
5
5
  "keywords": [
6
6
  "react",
@@ -33,7 +33,7 @@
33
33
  ],
34
34
  "dependencies": {
35
35
  "escape-string-regexp": "^5.0.0",
36
- "@react-navigation/core": "^8.0.0-alpha.33"
36
+ "@react-navigation/core": "^8.0.0-alpha.34"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@lynx-js/react": "*",
@@ -16,11 +16,25 @@ export type LynxStackPresentation = 'card' | 'formSheet';
16
16
  export type LynxStackNavigationOptions = {
17
17
  presentation?: LynxStackPresentation | undefined;
18
18
  contentStyle?: Lynx.CSSProperties | undefined;
19
+ /**
20
+ * Heights the sheet can rest at, as fractions of the screen, or
21
+ * `'fitToContents'` to measure the content. `formSheet` only, as is every
22
+ * option below. Names follow `@react-navigation/native-stack`.
23
+ */
24
+ sheetAllowedDetents?: number[] | 'fitToContents' | undefined;
25
+ sheetInitialDetentIndex?: number | 'last' | undefined;
26
+ /** Detents up to this one leave the content behind the sheet undimmed. */
27
+ sheetLargestUndimmedDetentIndex?: number | 'none' | 'last' | undefined;
28
+ sheetGrabberVisible?: boolean | undefined;
29
+ sheetCornerRadius?: number | 'systemDefault' | undefined;
30
+ sheetExpandsWhenScrolledToEdge?: boolean | undefined;
19
31
  };
20
32
 
21
33
  export type LynxStackNavigationEventMap = {
22
34
  transitionStart: { data: { closing: boolean } };
23
35
  transitionEnd: { data: { closing: boolean } };
36
+ /** The detent a `formSheet` settled at, as an index into its detents. */
37
+ sheetDetentChange: { data: { index: number } };
24
38
  };
25
39
 
26
40
  export type LynxStackNavigationProp<
@@ -11,6 +11,7 @@ import type {
11
11
  LynxStackNavigationHelpers,
12
12
  } from '../types';
13
13
  import { CardScreen } from './CardScreen';
14
+ import { SheetScreen } from './SheetScreen';
14
15
  import {
15
16
  type LynxStackViewState,
16
17
  type LynxStackViewStateAction,
@@ -44,7 +45,13 @@ function LynxStackViewContent({
44
45
  dispatch({ type: 'REMOVE_POPPED_ROUTE', key });
45
46
  };
46
47
 
47
- const onNativeDismiss = (key: string) => {
48
+ const onNativeDismiss = ({
49
+ key,
50
+ markNativelyDismissed,
51
+ }: {
52
+ key: string;
53
+ markNativelyDismissed: boolean;
54
+ }) => {
48
55
  const currentState = navigation.getState();
49
56
  const index = currentState.routes.findIndex((route) => route.key === key);
50
57
 
@@ -59,13 +66,16 @@ function LynxStackViewContent({
59
66
  }
60
67
 
61
68
  // The native side has already taken these screens off the stack, so the
62
- // reducer must not keep them rendered waiting for a pop animation.
63
- dispatch({
64
- type: 'ADD_NATIVELY_DISMISSED_ROUTES',
65
- keys: currentState.routes
66
- .slice(index, currentState.index + 1)
67
- .map((route) => route.key),
68
- });
69
+ // reducer must not keep them rendered waiting for a pop animation. A sheet
70
+ // whose dismissal was prevented is the exception: it is still there.
71
+ if (markNativelyDismissed) {
72
+ dispatch({
73
+ type: 'ADD_NATIVELY_DISMISSED_ROUTES',
74
+ keys: currentState.routes
75
+ .slice(index, currentState.index + 1)
76
+ .map((route) => route.key),
77
+ });
78
+ }
69
79
 
70
80
  navigation.dispatch({
71
81
  ...StackActions.pop(dismissCount),
@@ -86,7 +96,12 @@ function LynxStackViewContent({
86
96
  });
87
97
  };
88
98
 
89
- const cards = renderedRoutes.reduce<ReactElement[]>((result, route) => {
99
+ const cards: ReactElement[] = [];
100
+ // The native sheet is its own host, so these render outside the stack host
101
+ // rather than as screens in it.
102
+ const sheets: ReactElement[] = [];
103
+
104
+ renderedRoutes.forEach((route) => {
90
105
  const index = routeIndexByKey.get(route.key);
91
106
  const popped = poppedByKey.get(route.key);
92
107
  const descriptor = descriptors[route.key] ?? popped?.descriptor;
@@ -99,13 +114,38 @@ function LynxStackViewContent({
99
114
 
100
115
  const presentation = descriptor.options.presentation ?? 'card';
101
116
 
102
- if (presentation !== 'card') {
117
+ if (presentation !== 'card' && presentation !== 'formSheet') {
103
118
  throw new Error(
104
- `The route '${route.name}' uses the '${presentation}' presentation, which the Lynx stack does not support yet. Only 'card' is available while form sheet support lands in lynx-screens.`
119
+ `The route '${route.name}' uses the '${presentation}' presentation, which the Lynx stack does not support. Only 'card' and 'formSheet' are available.`
120
+ );
121
+ }
122
+
123
+ if (presentation === 'formSheet') {
124
+ if (index === 0) {
125
+ throw new Error(
126
+ `The route '${route.name}' cannot use the 'formSheet' presentation because it is the first route in the stack. Add a screen with the 'card' presentation before it.`
127
+ );
128
+ }
129
+
130
+ sheets.push(
131
+ <SheetScreen
132
+ key={route.key}
133
+ descriptor={descriptor}
134
+ navigation={navigation}
135
+ isFocused={index === state.index}
136
+ isPopped={popped != null}
137
+ onRemovePoppedRoute={onRemovePoppedRoute}
138
+ onNativeDismiss={(markNativelyDismissed) =>
139
+ onNativeDismiss({ key: route.key, markNativelyDismissed })
140
+ }
141
+ onNativeDismissPrevented={() => onNativeDismissPrevented(route.key)}
142
+ />
105
143
  );
144
+
145
+ return;
106
146
  }
107
147
 
108
- result.push(
148
+ cards.push(
109
149
  <CardScreen
110
150
  key={route.key}
111
151
  descriptor={descriptor}
@@ -115,15 +155,20 @@ function LynxStackViewContent({
115
155
  isPopped={popped != null}
116
156
  isDetached={index != null && index > state.index}
117
157
  onRemovePoppedRoute={onRemovePoppedRoute}
118
- onNativeDismiss={() => onNativeDismiss(route.key)}
158
+ onNativeDismiss={() =>
159
+ onNativeDismiss({ key: route.key, markNativelyDismissed: true })
160
+ }
119
161
  onNativeDismissPrevented={() => onNativeDismissPrevented(route.key)}
120
162
  />
121
163
  );
164
+ });
122
165
 
123
- return result;
124
- }, []);
125
-
126
- return <StackHostNativeComponent>{cards}</StackHostNativeComponent>;
166
+ return (
167
+ <>
168
+ <StackHostNativeComponent>{cards}</StackHostNativeComponent>
169
+ {sheets}
170
+ </>
171
+ );
127
172
  }
128
173
 
129
174
  export function LynxStackView({ state, navigation, descriptors }: Props) {
@@ -0,0 +1,120 @@
1
+ import {
2
+ NavigationProvider,
3
+ usePreventRemoveContext,
4
+ } from '@react-navigation/core';
5
+ import { FormSheetNativeComponent } from 'lynx-screens';
6
+
7
+ import type { LynxStackDescriptor, LynxStackNavigationHelpers } from '../types';
8
+
9
+ type Props = {
10
+ descriptor: LynxStackDescriptor;
11
+ navigation: LynxStackNavigationHelpers;
12
+ isFocused: boolean;
13
+ isPopped: boolean;
14
+ onRemovePoppedRoute: (key: string) => void;
15
+ onNativeDismiss: (markNativelyDismissed: boolean) => void;
16
+ onNativeDismissPrevented: () => void;
17
+ };
18
+
19
+ /**
20
+ * A `formSheet` route. The native sheet is its own host rather than a screen
21
+ * inside the stack, so this renders as a sibling of the stack host and opens
22
+ * and closes with focus instead of taking an activity mode.
23
+ */
24
+ export function SheetScreen({
25
+ descriptor,
26
+ navigation,
27
+ isFocused,
28
+ isPopped,
29
+ onRemovePoppedRoute,
30
+ onNativeDismiss,
31
+ onNativeDismissPrevented,
32
+ }: Props) {
33
+ const { preventedRoutes } = usePreventRemoveContext();
34
+
35
+ const { route, options } = descriptor;
36
+ const { contentStyle } = options;
37
+
38
+ // Prevention comes from `usePreventRemove` and nothing else, for the reason
39
+ // `CardScreen` spells out: a static option would have the native side block
40
+ // the gesture and the `onNativeDismissPrevented` round-trip pop the route
41
+ // anyway, since only a `beforeRemove` listener can cancel that dispatch.
42
+ const isRemovePrevented = preventedRoutes[route.key]?.preventRemove === true;
43
+
44
+ return (
45
+ <FormSheetNativeComponent
46
+ isOpen={isFocused}
47
+ detents={options.sheetAllowedDetents}
48
+ initialDetentIndex={options.sheetInitialDetentIndex}
49
+ largestUndimmedDetentIndex={options.sheetLargestUndimmedDetentIndex}
50
+ prefersGrabberVisible={options.sheetGrabberVisible}
51
+ preferredCornerRadius={options.sheetCornerRadius}
52
+ prefersScrollingExpandsWhenScrolledToEdge={
53
+ options.sheetExpandsWhenScrolledToEdge
54
+ }
55
+ preventNativeDismiss={isRemovePrevented}
56
+ nativeContainerStyle={{ backgroundColor: contentStyle?.backgroundColor }}
57
+ onWillAppear={() =>
58
+ navigation.emit({
59
+ type: 'transitionStart',
60
+ data: { closing: false },
61
+ target: route.key,
62
+ })
63
+ }
64
+ onDidAppear={() =>
65
+ navigation.emit({
66
+ type: 'transitionEnd',
67
+ data: { closing: false },
68
+ target: route.key,
69
+ })
70
+ }
71
+ onWillDisappear={() =>
72
+ navigation.emit({
73
+ type: 'transitionStart',
74
+ data: { closing: true },
75
+ target: route.key,
76
+ })
77
+ }
78
+ onDidDisappear={() => {
79
+ navigation.emit({
80
+ type: 'transitionEnd',
81
+ data: { closing: true },
82
+ target: route.key,
83
+ });
84
+
85
+ // The route stays rendered while the sheet animates out, which is what
86
+ // gives it something to animate. This is where it finally goes.
87
+ if (isPopped) {
88
+ onRemovePoppedRoute(route.key);
89
+ }
90
+ }}
91
+ onDetentChanged={(index) =>
92
+ navigation.emit({
93
+ type: 'sheetDetentChange',
94
+ data: { index },
95
+ target: route.key,
96
+ })
97
+ }
98
+ onNativeDismiss={() => onNativeDismiss(!isRemovePrevented)}
99
+ onNativeDismissPrevented={onNativeDismissPrevented}
100
+ >
101
+ <NavigationProvider navigation={descriptor.navigation} route={route}>
102
+ <view
103
+ style={{
104
+ display: 'flex',
105
+ flexDirection: 'column',
106
+ width: '100%',
107
+ // `fitToContents` measures the content, so it must not be told to
108
+ // fill the sheet.
109
+ ...(options.sheetAllowedDetents === 'fitToContents'
110
+ ? null
111
+ : { height: '100%' }),
112
+ ...contentStyle,
113
+ }}
114
+ >
115
+ {descriptor.render()}
116
+ </view>
117
+ </NavigationProvider>
118
+ </FormSheetNativeComponent>
119
+ );
120
+ }