@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@react-navigation/lynx",
3
- "version": "0.3.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.33"
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';
@@ -0,0 +1,7 @@
1
+ import * as React from 'react';
2
+
3
+ import type { LocaleDirection } from './types';
4
+
5
+ export const LocaleDirContext = React.createContext<LocaleDirection>('ltr');
6
+
7
+ LocaleDirContext.displayName = 'LocaleDirContext';
@@ -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 { type LinkingOptions, useLinking } from './useLinking';
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 <ThemeProvider value={theme}>{fallback}</ThemeProvider>;
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
- <BaseNavigationContainer
67
- {...restWithoutChildren}
68
- initialState={rest.initialState ?? linkingInitialState}
69
- theme={theme}
70
- onStateChange={handleStateChange}
71
- ref={refContainer}
72
- >
73
- {rest.children}
74
- </BaseNavigationContainer>
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 './useLinking';
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 type { LynxTheme } from './types';
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';
@@ -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';
@@ -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 = (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
+ }
@@ -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;
@@ -0,0 +1,4 @@
1
+ export {
2
+ MaterialDarkFallbackTheme as MaterialDarkTheme,
3
+ MaterialLightFallbackTheme as MaterialLightTheme,
4
+ } from './MaterialFallbackTheme';
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
+ }
@@ -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,
@@ -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
+ }