@react-navigation/lynx 0.3.0 → 0.4.0-canary-20260910-73bb3f72
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 +2 -2
- package/src/LinkingContext.ts +16 -0
- package/src/LocaleDirContext.ts +7 -0
- package/src/NavigationContainer.tsx +52 -11
- package/src/__tests__/getStateFromHref.test.ts +40 -0
- package/src/extractPathFromURL.ts +1 -1
- package/src/getStateFromHref.ts +49 -0
- package/src/index.tsx +8 -2
- package/src/stack/index.tsx +6 -3
- package/src/stack/types.ts +22 -0
- package/src/stack/views/LynxStackView.tsx +62 -17
- package/src/stack/views/SheetScreen.tsx +120 -0
- package/src/theming/MaterialFallbackTheme.ts +30 -0
- package/src/theming/MaterialTheme.ts +4 -0
- package/src/types.ts +34 -0
- package/src/useLinkBuilder.ts +138 -0
- package/src/useLinkTo.ts +27 -0
- package/src/useLinking.ts +1 -25
- package/src/useLocale.ts +15 -0
- package/src/useRoutePath.ts +27 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@react-navigation/lynx",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0-canary-20260910-73bb3f72",
|
|
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.
|
|
36
|
+
"@react-navigation/core": "^8.0.0-alpha.34"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@lynx-js/react": "*",
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ParamListBase } from '@react-navigation/core';
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
|
|
4
|
+
import type { LinkingOptions } from './types';
|
|
5
|
+
|
|
6
|
+
const MISSING_CONTEXT_ERROR = "Couldn't find a LinkingContext context.";
|
|
7
|
+
|
|
8
|
+
export const LinkingContext = React.createContext<{
|
|
9
|
+
options?: LinkingOptions<ParamListBase>;
|
|
10
|
+
}>({
|
|
11
|
+
get options(): any {
|
|
12
|
+
throw new Error(MISSING_CONTEXT_ERROR);
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
LinkingContext.displayName = 'LinkingContext';
|
|
@@ -1,21 +1,32 @@
|
|
|
1
1
|
import {
|
|
2
2
|
BaseNavigationContainer,
|
|
3
|
+
getActionFromState,
|
|
4
|
+
getPathFromState,
|
|
5
|
+
getStateFromPath,
|
|
3
6
|
type NavigationContainerProps,
|
|
4
7
|
type NavigationContainerRef,
|
|
5
8
|
type NavigationState,
|
|
6
9
|
type ParamListBase,
|
|
7
10
|
type Theme,
|
|
8
11
|
ThemeProvider,
|
|
12
|
+
validatePathConfig,
|
|
9
13
|
} from '@react-navigation/core';
|
|
10
14
|
import * as React from 'react';
|
|
11
15
|
|
|
16
|
+
import { LinkingContext } from './LinkingContext';
|
|
17
|
+
import { LocaleDirContext } from './LocaleDirContext';
|
|
12
18
|
import { LightTheme } from './theming/LightTheme';
|
|
13
|
-
import {
|
|
19
|
+
import type { LinkingOptions, LocaleDirection } from './types';
|
|
20
|
+
import { useLinking } from './useLinking';
|
|
21
|
+
|
|
22
|
+
const DEFAULT_DIRECTION: LocaleDirection = 'ltr';
|
|
14
23
|
|
|
15
24
|
export type NavigationContainerLynxProps<
|
|
16
25
|
ParamList extends {} = ParamListBase,
|
|
17
26
|
> = NavigationContainerProps & {
|
|
18
27
|
theme?: Theme | undefined;
|
|
28
|
+
/** Text direction of the components. Defaults to `'ltr'`. */
|
|
29
|
+
direction?: LocaleDirection | undefined;
|
|
19
30
|
/** Rendered while persisted state is being restored. */
|
|
20
31
|
fallback?: React.ReactNode | undefined;
|
|
21
32
|
/** Maps URLs handed over by the host onto navigation state. */
|
|
@@ -31,6 +42,7 @@ export type NavigationContainerLynxProps<
|
|
|
31
42
|
*/
|
|
32
43
|
export function NavigationContainer<ParamList extends {} = ParamListBase>({
|
|
33
44
|
theme = LightTheme,
|
|
45
|
+
direction = DEFAULT_DIRECTION,
|
|
34
46
|
fallback = null,
|
|
35
47
|
onStateChange,
|
|
36
48
|
linking,
|
|
@@ -52,6 +64,27 @@ export function NavigationContainer<ParamList extends {} = ParamListBase>({
|
|
|
52
64
|
rest.initialState != null ? undefined : getInitialState()
|
|
53
65
|
);
|
|
54
66
|
|
|
67
|
+
const linkingConfig = React.useMemo(() => {
|
|
68
|
+
if (linking == null) {
|
|
69
|
+
return { options: { enabled: false } };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (linking.config) {
|
|
73
|
+
validatePathConfig(linking.config);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
options: {
|
|
78
|
+
...linking,
|
|
79
|
+
enabled: linking.enabled !== false,
|
|
80
|
+
prefixes: linking.prefixes ?? ['*'],
|
|
81
|
+
getStateFromPath: linking.getStateFromPath ?? getStateFromPath,
|
|
82
|
+
getPathFromState: linking.getPathFromState ?? getPathFromState,
|
|
83
|
+
getActionFromState: linking.getActionFromState ?? getActionFromState,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}, [linking]);
|
|
87
|
+
|
|
55
88
|
const { children: _children, ...restWithoutChildren } = rest;
|
|
56
89
|
|
|
57
90
|
const handleStateChange = (state: Readonly<NavigationState> | undefined) => {
|
|
@@ -59,18 +92,26 @@ export function NavigationContainer<ParamList extends {} = ParamListBase>({
|
|
|
59
92
|
};
|
|
60
93
|
|
|
61
94
|
if (rest.children == null) {
|
|
62
|
-
return
|
|
95
|
+
return (
|
|
96
|
+
<LocaleDirContext.Provider value={direction}>
|
|
97
|
+
<ThemeProvider value={theme}>{fallback}</ThemeProvider>
|
|
98
|
+
</LocaleDirContext.Provider>
|
|
99
|
+
);
|
|
63
100
|
}
|
|
64
101
|
|
|
65
102
|
return (
|
|
66
|
-
<
|
|
67
|
-
{
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
103
|
+
<LocaleDirContext.Provider value={direction}>
|
|
104
|
+
<LinkingContext.Provider value={linkingConfig}>
|
|
105
|
+
<BaseNavigationContainer
|
|
106
|
+
{...restWithoutChildren}
|
|
107
|
+
initialState={rest.initialState ?? linkingInitialState}
|
|
108
|
+
theme={theme}
|
|
109
|
+
onStateChange={handleStateChange}
|
|
110
|
+
ref={refContainer}
|
|
111
|
+
>
|
|
112
|
+
{rest.children}
|
|
113
|
+
</BaseNavigationContainer>
|
|
114
|
+
</LinkingContext.Provider>
|
|
115
|
+
</LocaleDirContext.Provider>
|
|
75
116
|
);
|
|
76
117
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { expect, test } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { getStateFromHref } from '../getStateFromHref';
|
|
4
|
+
|
|
5
|
+
const config = {
|
|
6
|
+
screens: {
|
|
7
|
+
Home: '',
|
|
8
|
+
Detail: 'detail/:id',
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
test('parses an absolute path against the config', () => {
|
|
13
|
+
expect(getStateFromHref('/detail/42', { config }, undefined)).toEqual({
|
|
14
|
+
routes: [{ name: 'Detail', params: { id: '42' }, path: '/detail/42' }],
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('strips a matching prefix before parsing', () => {
|
|
19
|
+
expect(
|
|
20
|
+
getStateFromHref('myapp://detail/7', { prefixes: ['myapp://'], config }, undefined)
|
|
21
|
+
).toEqual({
|
|
22
|
+
routes: [{ name: 'Detail', params: { id: '7' }, path: '/detail/7' }],
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('rejects a URL that no prefix matches', () => {
|
|
27
|
+
expect(() =>
|
|
28
|
+
getStateFromHref('other://detail/7', { prefixes: ['myapp://'], config }, undefined)
|
|
29
|
+
).toThrow("Got invalid href 'other://detail/7'");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('rejects a URL the filter refuses', () => {
|
|
33
|
+
expect(() =>
|
|
34
|
+
getStateFromHref(
|
|
35
|
+
'myapp://detail/7',
|
|
36
|
+
{ prefixes: ['myapp://'], config, filter: () => false },
|
|
37
|
+
undefined
|
|
38
|
+
)
|
|
39
|
+
).toThrow("doesn't match the filter");
|
|
40
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import escapeStringRegexp from 'escape-string-regexp';
|
|
2
2
|
|
|
3
|
-
import type { LinkingPrefix } from './
|
|
3
|
+
import type { LinkingPrefix } from './types';
|
|
4
4
|
|
|
5
5
|
/** Verbatim from `@react-navigation/native`, which cannot be imported here. */
|
|
6
6
|
export function extractPathFromURL(prefixes: LinkingPrefix[], url: string) {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getStateFromPath,
|
|
3
|
+
type NavigationState,
|
|
4
|
+
type ParamListBase,
|
|
5
|
+
} from '@react-navigation/core';
|
|
6
|
+
|
|
7
|
+
import { extractPathFromURL } from './extractPathFromURL';
|
|
8
|
+
import type { LinkingOptions } from './types';
|
|
9
|
+
|
|
10
|
+
export function getStateFromHref(
|
|
11
|
+
href: string,
|
|
12
|
+
options: LinkingOptions<ParamListBase> | undefined,
|
|
13
|
+
previous: NavigationState | undefined
|
|
14
|
+
): ReturnType<typeof getStateFromPath> {
|
|
15
|
+
const {
|
|
16
|
+
prefixes,
|
|
17
|
+
filter,
|
|
18
|
+
config,
|
|
19
|
+
getStateFromPath: getStateFromPathHelper = getStateFromPath,
|
|
20
|
+
} = options || {};
|
|
21
|
+
|
|
22
|
+
let path;
|
|
23
|
+
|
|
24
|
+
if (href.startsWith('/')) {
|
|
25
|
+
path = href;
|
|
26
|
+
} else if (href) {
|
|
27
|
+
if (filter && !filter(href)) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`Failed to parse href '${href}'. It doesn't match the filter specified in linking config.`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (prefixes == null || prefixes.length === 0) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`Failed to parse href '${href}'. It doesn't start with '/' and no prefixes are defined in linking config.`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
path = extractPathFromURL(prefixes, href);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (path == null) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Got invalid href '${href}'. It must start with '/' or match one of the prefixes: ${options?.prefixes?.map((prefix) => `'${prefix}'`).join(', ')}.`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return getStateFromPathHelper(path, config, previous);
|
|
49
|
+
}
|
package/src/index.tsx
CHANGED
|
@@ -2,13 +2,20 @@
|
|
|
2
2
|
// React Native: it owns everything Lynx-specific that every navigator needs,
|
|
3
3
|
// and re-exports core so apps have a single import surface.
|
|
4
4
|
export { createStaticNavigation } from './createStaticNavigation';
|
|
5
|
-
export
|
|
5
|
+
export { LinkingContext } from './LinkingContext';
|
|
6
|
+
export { LocaleDirContext } from './LocaleDirContext';
|
|
6
7
|
export {
|
|
7
8
|
NavigationContainer,
|
|
8
9
|
type NavigationContainerLynxProps,
|
|
9
10
|
} from './NavigationContainer';
|
|
10
11
|
export { DarkTheme } from './theming/DarkTheme';
|
|
11
12
|
export { LightTheme as DefaultTheme } from './theming/LightTheme';
|
|
13
|
+
export { MaterialDarkTheme, MaterialLightTheme } from './theming/MaterialTheme';
|
|
14
|
+
export * from './types';
|
|
15
|
+
export { useLinkBuilder } from './useLinkBuilder';
|
|
16
|
+
export { useLinkTo } from './useLinkTo';
|
|
17
|
+
export { useLocale } from './useLocale';
|
|
18
|
+
export { useRoutePath } from './useRoutePath';
|
|
12
19
|
|
|
13
20
|
export {
|
|
14
21
|
getInitialURL,
|
|
@@ -18,6 +25,5 @@ export {
|
|
|
18
25
|
URL_EVENT,
|
|
19
26
|
type NavigationInitData,
|
|
20
27
|
} from './linking';
|
|
21
|
-
export type { LinkingOptions, LinkingPrefix } from './useLinking';
|
|
22
28
|
|
|
23
29
|
export * from '@react-navigation/core';
|
package/src/stack/index.tsx
CHANGED
|
@@ -7,17 +7,20 @@ export {
|
|
|
7
7
|
type LynxStackTypeBag,
|
|
8
8
|
} from './navigators/createLynxStackNavigator';
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Views
|
|
12
|
+
*/
|
|
13
|
+
export { LynxStackView } from './views/LynxStackView';
|
|
14
|
+
|
|
10
15
|
/**
|
|
11
16
|
* Types
|
|
12
17
|
*/
|
|
13
18
|
export type {
|
|
14
|
-
LynxStackDescriptor,
|
|
15
|
-
LynxStackDescriptorMap,
|
|
16
19
|
LynxStackNavigationEventMap,
|
|
17
|
-
LynxStackNavigationHelpers,
|
|
18
20
|
LynxStackNavigationOptions,
|
|
19
21
|
LynxStackNavigationProp,
|
|
20
22
|
LynxStackNavigatorProps,
|
|
23
|
+
LynxStackOptionsArgs,
|
|
21
24
|
LynxStackPresentation,
|
|
22
25
|
LynxStackScreenProps,
|
|
23
26
|
} from './types';
|
package/src/stack/types.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
StackActionHelpers,
|
|
9
9
|
StackNavigationState,
|
|
10
10
|
StackRouterOptions,
|
|
11
|
+
Theme,
|
|
11
12
|
} from '@react-navigation/core';
|
|
12
13
|
import type * as Lynx from '@lynx-js/types';
|
|
13
14
|
|
|
@@ -16,11 +17,25 @@ export type LynxStackPresentation = 'card' | 'formSheet';
|
|
|
16
17
|
export type LynxStackNavigationOptions = {
|
|
17
18
|
presentation?: LynxStackPresentation | undefined;
|
|
18
19
|
contentStyle?: Lynx.CSSProperties | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Heights the sheet can rest at, as fractions of the screen, or
|
|
22
|
+
* `'fitToContents'` to measure the content. `formSheet` only, as is every
|
|
23
|
+
* option below. Names follow `@react-navigation/native-stack`.
|
|
24
|
+
*/
|
|
25
|
+
sheetAllowedDetents?: number[] | 'fitToContents' | undefined;
|
|
26
|
+
sheetInitialDetentIndex?: number | 'last' | undefined;
|
|
27
|
+
/** Detents up to this one leave the content behind the sheet undimmed. */
|
|
28
|
+
sheetLargestUndimmedDetentIndex?: number | 'none' | 'last' | undefined;
|
|
29
|
+
sheetGrabberVisible?: boolean | undefined;
|
|
30
|
+
sheetCornerRadius?: number | 'systemDefault' | undefined;
|
|
31
|
+
sheetExpandsWhenScrolledToEdge?: boolean | undefined;
|
|
19
32
|
};
|
|
20
33
|
|
|
21
34
|
export type LynxStackNavigationEventMap = {
|
|
22
35
|
transitionStart: { data: { closing: boolean } };
|
|
23
36
|
transitionEnd: { data: { closing: boolean } };
|
|
37
|
+
/** The detent a `formSheet` settled at, as an index into its detents. */
|
|
38
|
+
sheetDetentChange: { data: { index: number } };
|
|
24
39
|
};
|
|
25
40
|
|
|
26
41
|
export type LynxStackNavigationProp<
|
|
@@ -43,6 +58,13 @@ export type LynxStackScreenProps<
|
|
|
43
58
|
route: RouteProp<ParamList, RouteName>;
|
|
44
59
|
};
|
|
45
60
|
|
|
61
|
+
export type LynxStackOptionsArgs<
|
|
62
|
+
ParamList extends ParamListBase,
|
|
63
|
+
RouteName extends keyof ParamList = keyof ParamList,
|
|
64
|
+
> = LynxStackScreenProps<ParamList, RouteName> & {
|
|
65
|
+
theme: Theme;
|
|
66
|
+
};
|
|
67
|
+
|
|
46
68
|
export type LynxStackNavigationHelpers = NavigationHelpers<
|
|
47
69
|
ParamListBase,
|
|
48
70
|
LynxStackNavigationEventMap
|
|
@@ -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 = (
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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={() =>
|
|
158
|
+
onNativeDismiss={() =>
|
|
159
|
+
onNativeDismiss({ key: route.key, markNativelyDismissed: true })
|
|
160
|
+
}
|
|
119
161
|
onNativeDismissPrevented={() => onNativeDismissPrevented(route.key)}
|
|
120
162
|
/>
|
|
121
163
|
);
|
|
164
|
+
});
|
|
122
165
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { LynxTheme } from '../types';
|
|
2
|
+
|
|
3
|
+
import { fonts } from './fonts';
|
|
4
|
+
|
|
5
|
+
/** Same palette as `@react-navigation/native`'s Material fallback themes. */
|
|
6
|
+
export const MaterialLightFallbackTheme = {
|
|
7
|
+
dark: false,
|
|
8
|
+
colors: {
|
|
9
|
+
primary: '#6750a4',
|
|
10
|
+
background: '#f3edf7',
|
|
11
|
+
card: '#fef7ff',
|
|
12
|
+
text: '#1d1b20',
|
|
13
|
+
border: '#cac4d0',
|
|
14
|
+
notification: '#ba1a1a',
|
|
15
|
+
},
|
|
16
|
+
fonts,
|
|
17
|
+
} as const satisfies LynxTheme;
|
|
18
|
+
|
|
19
|
+
export const MaterialDarkFallbackTheme = {
|
|
20
|
+
dark: true,
|
|
21
|
+
colors: {
|
|
22
|
+
primary: '#d0bcff',
|
|
23
|
+
background: '#211f26',
|
|
24
|
+
card: '#141218',
|
|
25
|
+
text: '#e6e0e9',
|
|
26
|
+
border: '#49454f',
|
|
27
|
+
notification: '#ffb4ab',
|
|
28
|
+
},
|
|
29
|
+
fonts,
|
|
30
|
+
} as const satisfies LynxTheme;
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
getActionFromState as getActionFromStateDefault,
|
|
3
|
+
getPathFromState as getPathFromStateDefault,
|
|
4
|
+
getStateFromPath as getStateFromPathDefault,
|
|
5
|
+
} from '@react-navigation/core';
|
|
6
|
+
|
|
1
7
|
type FontStyle = {
|
|
2
8
|
fontFamily: string;
|
|
3
9
|
fontWeight:
|
|
@@ -40,3 +46,31 @@ export interface LynxTheme {
|
|
|
40
46
|
declare module '@react-navigation/core' {
|
|
41
47
|
interface Theme extends LynxTheme {}
|
|
42
48
|
}
|
|
49
|
+
|
|
50
|
+
export type LocaleDirection = 'ltr' | 'rtl';
|
|
51
|
+
|
|
52
|
+
// core keeps its `Options` type internal; derive it from the consumer so it
|
|
53
|
+
// cannot drift.
|
|
54
|
+
type LinkingConfig<ParamList extends {}> = NonNullable<
|
|
55
|
+
Parameters<typeof getStateFromPathDefault<ParamList>>[1]
|
|
56
|
+
>;
|
|
57
|
+
|
|
58
|
+
export type LinkingPrefix = '*' | (string & {});
|
|
59
|
+
|
|
60
|
+
export type LinkingOptions<ParamList extends {}> = {
|
|
61
|
+
/** Defaults to true when a config is given. */
|
|
62
|
+
enabled?: boolean | undefined;
|
|
63
|
+
prefixes?: LinkingPrefix[] | undefined;
|
|
64
|
+
/** Rejects a URL before its prefix is stripped. */
|
|
65
|
+
filter?: ((url: string) => boolean) | undefined;
|
|
66
|
+
config?: LinkingConfig<ParamList> | undefined;
|
|
67
|
+
/** Overrides where the launch URL comes from. */
|
|
68
|
+
getInitialURL?: (() => string | undefined) | undefined;
|
|
69
|
+
/** Overrides how later URLs arrive. */
|
|
70
|
+
subscribe?:
|
|
71
|
+
| ((listener: (url: string) => void) => undefined | void | (() => void))
|
|
72
|
+
| undefined;
|
|
73
|
+
getStateFromPath?: typeof getStateFromPathDefault | undefined;
|
|
74
|
+
getPathFromState?: typeof getPathFromStateDefault | undefined;
|
|
75
|
+
getActionFromState?: typeof getActionFromStateDefault | undefined;
|
|
76
|
+
} & { [key: string]: unknown };
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CommonActions,
|
|
3
|
+
getActionFromState,
|
|
4
|
+
getPathFromState,
|
|
5
|
+
IsScreenContext,
|
|
6
|
+
NavigationContainerRefContext,
|
|
7
|
+
useStateForPath,
|
|
8
|
+
} from '@react-navigation/core';
|
|
9
|
+
import * as React from 'react';
|
|
10
|
+
|
|
11
|
+
import { getStateFromHref } from './getStateFromHref';
|
|
12
|
+
import { LinkingContext } from './LinkingContext';
|
|
13
|
+
|
|
14
|
+
type MinimalState = {
|
|
15
|
+
routes: [
|
|
16
|
+
{
|
|
17
|
+
name: string;
|
|
18
|
+
params?: object | undefined;
|
|
19
|
+
state?: MinimalState | undefined;
|
|
20
|
+
},
|
|
21
|
+
];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Helper to build a href for a screen based on the linking options.
|
|
26
|
+
*/
|
|
27
|
+
export function useBuildHref() {
|
|
28
|
+
const isScreen = React.use(IsScreenContext);
|
|
29
|
+
const { options } = React.use(LinkingContext);
|
|
30
|
+
const focusedRouteState = useStateForPath();
|
|
31
|
+
|
|
32
|
+
const getPathFromStateHelper = options?.getPathFromState ?? getPathFromState;
|
|
33
|
+
|
|
34
|
+
const buildHref = React.useCallback(
|
|
35
|
+
(name: string, params?: object) => {
|
|
36
|
+
if (options?.enabled === false) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const stateForRoute: MinimalState = {
|
|
41
|
+
routes: [{ name, params }],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const constructState = (
|
|
45
|
+
state: MinimalState | undefined
|
|
46
|
+
): MinimalState => {
|
|
47
|
+
if (state) {
|
|
48
|
+
const route = state.routes[0];
|
|
49
|
+
|
|
50
|
+
// Inside a screen at the innermost route the provided state replaces
|
|
51
|
+
// it: the target is a sibling.
|
|
52
|
+
if (isScreen && !route.state) {
|
|
53
|
+
return stateForRoute;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
routes: [
|
|
58
|
+
{
|
|
59
|
+
...route,
|
|
60
|
+
state: constructState(route.state),
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// No nested state left: the target is a child of this route, which is
|
|
67
|
+
// the case when a navigator builds hrefs for its own routes.
|
|
68
|
+
return stateForRoute;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const state = constructState(focusedRouteState);
|
|
72
|
+
|
|
73
|
+
return getPathFromStateHelper(state, options?.config);
|
|
74
|
+
},
|
|
75
|
+
[
|
|
76
|
+
options?.enabled,
|
|
77
|
+
options?.config,
|
|
78
|
+
isScreen,
|
|
79
|
+
focusedRouteState,
|
|
80
|
+
getPathFromStateHelper,
|
|
81
|
+
]
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
return buildHref;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Helper to build a navigation action from a href based on the linking options.
|
|
89
|
+
*/
|
|
90
|
+
export function useBuildAction() {
|
|
91
|
+
const navigation = React.use(NavigationContainerRefContext);
|
|
92
|
+
|
|
93
|
+
if (navigation === undefined) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
"Couldn't find a navigation object. Is your component inside NavigationContainer?"
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const { options } = React.use(LinkingContext);
|
|
100
|
+
|
|
101
|
+
const getActionFromStateHelper =
|
|
102
|
+
options?.getActionFromState ?? getActionFromState;
|
|
103
|
+
|
|
104
|
+
const buildAction = React.useCallback(
|
|
105
|
+
(href: string) => {
|
|
106
|
+
const rootState = navigation.getRootState();
|
|
107
|
+
const state = getStateFromHref(href, options, rootState);
|
|
108
|
+
|
|
109
|
+
if (state) {
|
|
110
|
+
const action =
|
|
111
|
+
getActionFromStateHelper(state, options?.config) ??
|
|
112
|
+
CommonActions.reset(state);
|
|
113
|
+
|
|
114
|
+
return { target: rootState?.key, ...action };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
throw new Error(`Failed to parse href '${href}' to a navigation state.`);
|
|
118
|
+
},
|
|
119
|
+
[navigation, options, getActionFromStateHelper]
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
return buildAction;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Helpers to build href or action based on the linking options.
|
|
127
|
+
*
|
|
128
|
+
* @returns `buildHref` to build an `href` for screen and `buildAction` to build an action from an `href`.
|
|
129
|
+
*/
|
|
130
|
+
export function useLinkBuilder() {
|
|
131
|
+
const buildHref = useBuildHref();
|
|
132
|
+
const buildAction = useBuildAction();
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
buildHref,
|
|
136
|
+
buildAction,
|
|
137
|
+
};
|
|
138
|
+
}
|
package/src/useLinkTo.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { NavigationRootContext } from '@react-navigation/core';
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
|
|
4
|
+
import { useBuildAction } from './useLinkBuilder';
|
|
5
|
+
|
|
6
|
+
export function useLinkTo() {
|
|
7
|
+
const navigation = React.use(NavigationRootContext);
|
|
8
|
+
|
|
9
|
+
if (navigation === undefined) {
|
|
10
|
+
throw new Error(
|
|
11
|
+
"Couldn't find a navigation object. Is your component inside NavigationContainer?"
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const buildAction = useBuildAction();
|
|
16
|
+
|
|
17
|
+
const linkTo = React.useCallback(
|
|
18
|
+
(href: string) => {
|
|
19
|
+
const action = buildAction(href);
|
|
20
|
+
|
|
21
|
+
navigation.dispatch(action);
|
|
22
|
+
},
|
|
23
|
+
[buildAction, navigation]
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
return linkTo;
|
|
27
|
+
}
|
package/src/useLinking.ts
CHANGED
|
@@ -9,36 +9,12 @@ import {
|
|
|
9
9
|
import * as React from 'react';
|
|
10
10
|
|
|
11
11
|
import { extractPathFromURL } from './extractPathFromURL';
|
|
12
|
+
import type { LinkingOptions, LinkingPrefix } from './types';
|
|
12
13
|
import {
|
|
13
14
|
getInitialURL as getInitialURLDefault,
|
|
14
15
|
subscribe as subscribeDefault,
|
|
15
16
|
} from './linking';
|
|
16
17
|
|
|
17
|
-
// core keeps its `Options` type internal; derive it from the consumer so it
|
|
18
|
-
// cannot drift.
|
|
19
|
-
type LinkingConfig<ParamList extends {}> = NonNullable<
|
|
20
|
-
Parameters<typeof getStateFromPathDefault<ParamList>>[1]
|
|
21
|
-
>;
|
|
22
|
-
|
|
23
|
-
export type LinkingPrefix = '*' | (string & {});
|
|
24
|
-
|
|
25
|
-
export type LinkingOptions<ParamList extends {}> = {
|
|
26
|
-
/** Defaults to true when a config is given. */
|
|
27
|
-
enabled?: boolean | undefined;
|
|
28
|
-
prefixes?: LinkingPrefix[] | undefined;
|
|
29
|
-
/** Rejects a URL before its prefix is stripped. */
|
|
30
|
-
filter?: ((url: string) => boolean) | undefined;
|
|
31
|
-
config?: LinkingConfig<ParamList> | undefined;
|
|
32
|
-
/** Overrides where the launch URL comes from. */
|
|
33
|
-
getInitialURL?: (() => string | undefined) | undefined;
|
|
34
|
-
/** Overrides how later URLs arrive. */
|
|
35
|
-
subscribe?:
|
|
36
|
-
| ((listener: (url: string) => void) => undefined | void | (() => void))
|
|
37
|
-
| undefined;
|
|
38
|
-
getStateFromPath?: typeof getStateFromPathDefault | undefined;
|
|
39
|
-
getActionFromState?: typeof getActionFromStateDefault | undefined;
|
|
40
|
-
} & { [key: string]: unknown };
|
|
41
|
-
|
|
42
18
|
/** The `getStateFromHref` contract of `@react-navigation/native`. */
|
|
43
19
|
function extractPath(
|
|
44
20
|
url: string,
|
package/src/useLocale.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
import { LocaleDirContext } from './LocaleDirContext';
|
|
4
|
+
|
|
5
|
+
export function useLocale() {
|
|
6
|
+
const direction = React.use(LocaleDirContext);
|
|
7
|
+
|
|
8
|
+
if (direction === undefined) {
|
|
9
|
+
throw new Error(
|
|
10
|
+
"Couldn't determine the text direction. Is your component inside NavigationContainer?"
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return { direction };
|
|
15
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { getPathFromState, useStateForPath } from '@react-navigation/core';
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
|
|
4
|
+
import { LinkingContext } from './LinkingContext';
|
|
5
|
+
|
|
6
|
+
export function useRoutePath() {
|
|
7
|
+
const { options } = React.use(LinkingContext);
|
|
8
|
+
const state = useStateForPath();
|
|
9
|
+
|
|
10
|
+
if (state === undefined) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
"Couldn't find a state for the route object. Is your component inside a screen in a navigator?"
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const getPathFromStateHelper = options?.getPathFromState ?? getPathFromState;
|
|
17
|
+
|
|
18
|
+
const path = React.useMemo(() => {
|
|
19
|
+
if (options?.enabled === false) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return getPathFromStateHelper(state, options?.config);
|
|
24
|
+
}, [options?.enabled, options?.config, state, getPathFromStateHelper]);
|
|
25
|
+
|
|
26
|
+
return path;
|
|
27
|
+
}
|