@sigmela/router 0.0.11

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.
Files changed (44) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +346 -0
  3. package/lib/module/Navigation.js +74 -0
  4. package/lib/module/NavigationStack.js +72 -0
  5. package/lib/module/Router.js +571 -0
  6. package/lib/module/RouterContext.js +33 -0
  7. package/lib/module/ScreenStackItem.js +61 -0
  8. package/lib/module/StackRenderer.js +29 -0
  9. package/lib/module/TabBar/RenderTabBar.js +122 -0
  10. package/lib/module/TabBar/TabBar.js +74 -0
  11. package/lib/module/TabBar/TabBarContext.js +4 -0
  12. package/lib/module/TabBar/useTabBar.js +11 -0
  13. package/lib/module/createController.js +5 -0
  14. package/lib/module/index.js +14 -0
  15. package/lib/module/package.json +1 -0
  16. package/lib/module/types.js +3 -0
  17. package/lib/typescript/package.json +1 -0
  18. package/lib/typescript/src/Navigation.d.ts +8 -0
  19. package/lib/typescript/src/NavigationStack.d.ts +30 -0
  20. package/lib/typescript/src/Router.d.ts +70 -0
  21. package/lib/typescript/src/RouterContext.d.ts +19 -0
  22. package/lib/typescript/src/ScreenStackItem.d.ts +12 -0
  23. package/lib/typescript/src/StackRenderer.d.ts +6 -0
  24. package/lib/typescript/src/TabBar/RenderTabBar.d.ts +8 -0
  25. package/lib/typescript/src/TabBar/TabBar.d.ts +43 -0
  26. package/lib/typescript/src/TabBar/TabBarContext.d.ts +3 -0
  27. package/lib/typescript/src/TabBar/useTabBar.d.ts +2 -0
  28. package/lib/typescript/src/createController.d.ts +14 -0
  29. package/lib/typescript/src/index.d.ts +15 -0
  30. package/lib/typescript/src/types.d.ts +244 -0
  31. package/package.json +166 -0
  32. package/src/Navigation.tsx +102 -0
  33. package/src/NavigationStack.ts +106 -0
  34. package/src/Router.ts +684 -0
  35. package/src/RouterContext.tsx +58 -0
  36. package/src/ScreenStackItem.tsx +64 -0
  37. package/src/StackRenderer.tsx +41 -0
  38. package/src/TabBar/RenderTabBar.tsx +154 -0
  39. package/src/TabBar/TabBar.ts +106 -0
  40. package/src/TabBar/TabBarContext.ts +4 -0
  41. package/src/TabBar/useTabBar.ts +10 -0
  42. package/src/createController.ts +27 -0
  43. package/src/index.ts +24 -0
  44. package/src/types.ts +272 -0
package/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 https://github.com/bogoslavskiy
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ of this software and associated documentation files (the "Software"), to deal
6
+ in the Software without restriction, including without limitation the rights
7
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the Software is
9
+ furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all
12
+ copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,346 @@
1
+ # Router for React Native
2
+
3
+ [![npm version](https://badge.fury.io/js/%40sigmela%2Frouter.svg)](https://www.npmjs.com/package/@sigmela/router)
4
+
5
+ Lightweight, predictable navigation for React Native built on top of react-native-screens. It provides:
6
+ - Stack navigation with URL-like paths and typed params
7
+ - Bottom tab navigation via a simple builder API
8
+ - A global overlay stack (e.g., auth modal) rendered above tabs/root
9
+ - An imperative API with idempotent navigation and O(1) per-stack updates
10
+
11
+ Installation
12
+
13
+ ```bash
14
+ yarn add @sigmela/router react-native-screens
15
+ ```
16
+
17
+ Make sure react-native-screens is properly installed and configured in your app.
18
+
19
+ Quick start (single stack)
20
+
21
+ ```tsx
22
+ import { NavigationStack, Router, Navigation, useRouter, useParams, useQueryParams } from '@sigmela/router';
23
+
24
+ function HomeScreen() {
25
+ const router = useRouter();
26
+ return (
27
+ <Button
28
+ title="Open details"
29
+ onPress={() => router.navigate('/details/42?from=home')}
30
+ />
31
+ );
32
+ }
33
+
34
+ function DetailsScreen() {
35
+ const { id } = useParams<{ id: string }>();
36
+ const { from } = useQueryParams<{ from?: string }>();
37
+ return <Text>{`Details id=${id} from=${from ?? 'n/a'}`}</Text>;
38
+ }
39
+
40
+ const rootStack = new NavigationStack()
41
+ .addScreen('/', HomeScreen, { header: { title: 'Home' } })
42
+ .addScreen('/details/:id', DetailsScreen, { header: { title: 'Details' } });
43
+
44
+ const router = new Router({ root: rootStack });
45
+
46
+ export default function App() {
47
+ return <Navigation router={router} />;
48
+ }
49
+ ```
50
+
51
+ Quick start (tabs + global stack)
52
+
53
+ ```tsx
54
+ import { NavigationStack, Router, Navigation, TabBar } from '@sigmela/router';
55
+
56
+ const homeStack = new NavigationStack()
57
+ .addScreen('/', HomeScreen, { header: { title: 'Home' } });
58
+
59
+ const catalogStack = new NavigationStack()
60
+ .addScreen('/catalog', CatalogScreen, { header: { title: 'Catalog' } })
61
+ .addScreen('/catalog/products/:productId', ProductScreen, {
62
+ header: { title: 'Product' }
63
+ });
64
+
65
+ const globalStack = new NavigationStack()
66
+ .addModal('/auth', AuthScreen, {
67
+ header: { title: 'Sign in' },
68
+ });
69
+
70
+ const tabBar = new TabBar({ labeled: true })
71
+ .addTab({ stack: homeStack, title: 'Home', icon: { sfSymbol: 'house' } })
72
+ .addTab({ stack: catalogStack, title: 'Catalog', icon: { sfSymbol: 'bag' } });
73
+
74
+ const router = new Router({ root: tabBar, global: globalStack });
75
+
76
+ export default function App() {
77
+ return <Navigation router={router} />;
78
+ }
79
+ ```
80
+
81
+ ## Navigation Appearance
82
+
83
+ You can customize the navigation appearance using the `appearance` prop:
84
+
85
+ ```tsx
86
+ import { Navigation, NavigationAppearance } from '@sigmela/router';
87
+
88
+ const appearance: NavigationAppearance = {
89
+ tabBar: {
90
+ // Android-specific
91
+ backgroundColor: '#ffffff',
92
+ tabBarItemStyle: {
93
+ titleFontColor: '#999999',
94
+ titleFontColorActive: '#007AFF',
95
+ titleFontSize: 12,
96
+ titleFontWeight: '600',
97
+ iconColor: '#999999',
98
+ iconColorActive: '#007AFF',
99
+ rippleColor: '#00000020',
100
+ activeIndicatorColor: '#007AFF',
101
+ },
102
+ // iOS-specific
103
+ tintColor: '#007AFF',
104
+ standardAppearance: {
105
+ tabBarBackgroundColor: '#ffffff',
106
+ tabBarShadowColor: 'transparent',
107
+ },
108
+ scrollEdgeAppearance: {
109
+ tabBarBackgroundColor: 'rgba(255,255,255,0.9)',
110
+ tabBarShadowColor: 'transparent',
111
+ },
112
+ },
113
+ screenStyle: {
114
+ backgroundColor: '#ffffff',
115
+ },
116
+ };
117
+
118
+ export default function App() {
119
+ return <Navigation router={router} appearance={appearance} />;
120
+ }
121
+ ```
122
+
123
+ Core concepts
124
+
125
+ - Router: central coordinator. Holds slices (histories) per stack, active tab index, and the visible route.
126
+ - NavigationStack: define stack routes with path patterns using path-to-regexp.
127
+ - TabBar: builder for bottom tabs; each tab may reference a stack or a single screen.
128
+ - Global stack: a separate stack rendered above tabs/root, ideal for modals like auth.
129
+ - Navigation component: renders current root layer (tabs or root stack) and the global overlay.
130
+
131
+ API reference
132
+
133
+ NavigationStack
134
+
135
+ - constructor(idOrOptions?, maybeOptions?)
136
+ - Overloads:
137
+ - new NavigationStack()
138
+ - new NavigationStack(id: string)
139
+ - new NavigationStack(defaultOptions: ScreenOptions)
140
+ - new NavigationStack(id: string, defaultOptions: ScreenOptions)
141
+ - addScreen(path: string, component: React.ComponentType, options?: ScreenOptions): this
142
+ - addModal(path: string, component: React.ComponentType, options?: ScreenOptions): this
143
+ - Convenience method that automatically sets `stackPresentation: 'modal'`
144
+ - getId(): string
145
+ - getDefaultOptions(): ScreenOptions | undefined
146
+
147
+ Router
148
+
149
+ - constructor({ root, global?, screenOptions? })
150
+ - root: TabBar | NavigationStack
151
+ - global: optional NavigationStack rendered on top (modal layer)
152
+ - screenOptions: global ScreenOptions overrides merged into each screen
153
+ - navigate(path: string): void
154
+ - Matches a route by pathname, switches tab if needed, pushes a new history item
155
+ - Duplicate navigate to the same top screen with the same params is ignored
156
+ - replace(path: string): void
157
+ - Replaces the top history item. If the top stack changes, both stack slices are updated incrementally to avoid stale entries [[memory:6631860]].
158
+ - goBack(): void
159
+ - Pops from the highest priority layer that can pop: global → current tab → root
160
+ - “Seed” screens (the very first screen of a stack) are protected from popping
161
+ - setRoot(nextRoot: TabBar | NavigationStack, options?: { transition?: ScreenOptions['stackAnimation'] }): void
162
+ - Switch between auth flow and main app, etc.; reseeds the new root
163
+ - transition is applied to the root layer when changing
164
+ - onTabIndexChange(index: number): void and setActiveTabIndex(index: number): void
165
+ - ensureTabSeed(index: number): void
166
+ - Ensures the first screen of a tab stack is seeded when the tab becomes active
167
+ - getVisibleRoute(): {
168
+ routeId: string; stackId?: string; tabIndex?: number; scope: 'global' | 'tab' | 'root'; params?; query?; path?; pattern?
169
+ } | null
170
+ - subscribe(listener): unsubscribe
171
+ - subscribeStack(stackId, listener): unsubscribe
172
+ - subscribeActiveTab(listener): unsubscribe
173
+ - getStackHistory(stackId): HistoryItem[] (useful for debugging/analytics)
174
+ - hasTabBar(): boolean, getRootStackId(): string | undefined, getGlobalStackId(): string | undefined, getRootTransition(): ScreenOptions['stackAnimation'] | undefined
175
+
176
+ Components
177
+
178
+ - Navigation: top-level view that renders the root layer and the global overlay. Usage: `<Navigation router={router} appearance={appearance} />`.
179
+ - StackRenderer: renders a single `NavigationStack` (advanced use, usually not needed directly).
180
+
181
+ Hooks
182
+
183
+ - useRouter(): Router
184
+ - useCurrentRoute(): VisibleRoute
185
+ - useParams<TParams>(): TParams
186
+ - useQueryParams<TQuery>(): TQuery
187
+ - useRoute(): { params, query, pattern?, path? }
188
+
189
+ TabBar builder
190
+
191
+ ```ts
192
+ new TabBar({
193
+ sidebarAdaptable?: boolean,
194
+ disablePageAnimations?: boolean,
195
+ hapticFeedbackEnabled?: boolean,
196
+ scrollEdgeAppearance?: 'default' | 'opaque' | 'transparent',
197
+ minimizeBehavior?: 'automatic' | 'onScrollDown' | 'onScrollUp' | 'never',
198
+ })
199
+ .addTab({
200
+ stack?: NavigationStack,
201
+ screen?: React.ComponentType,
202
+ title?: string,
203
+ badge?: string,
204
+ icon?: ImageSource | AppleIcon | (({ focused }: { focused: boolean }) => ImageSource | AppleIcon),
205
+ activeTintColor?: ColorValue,
206
+ hidden?: boolean,
207
+ testID?: string,
208
+ role?: 'search',
209
+ freezeOnBlur?: boolean,
210
+ lazy?: boolean,
211
+ iconInsets?: { top?: number; bottom?: number; left?: number; right?: number },
212
+ })
213
+ ```
214
+
215
+ You can update badges at runtime via:
216
+ - setBadge(tabIndex, badge: string | null)
217
+ - setTabBarConfig(partial)
218
+
219
+ For styling, use the `appearance` prop on the Navigation component instead.
220
+
221
+ Screen options
222
+
223
+ ScreenOptions map directly to props of react-native-screens `ScreenStackItem` (e.g., header, stackPresentation, stackAnimation, gestureEnabled, etc.).
224
+ - **header**: controls the navigation header. If not specified, the header is hidden by default.
225
+ - Per-screen options come from `addScreen(path, component, options)`
226
+ - Per-stack defaults via `new NavigationStack(defaultOptions)`
227
+ - Global overrides via `new Router({ screenOptions })`
228
+ The effective options are merged in this order: stack defaults → per-screen → router overrides.
229
+
230
+ Header configuration:
231
+ ```tsx
232
+ // Header with title (visible)
233
+ { header: { title: 'My Screen' } }
234
+
235
+ // Hidden header (explicit)
236
+ { header: { hidden: true } }
237
+
238
+ // No header specified = hidden by default
239
+ { /* header will be hidden automatically */ }
240
+
241
+ // Custom header with background color
242
+ { header: { title: 'Settings', backgroundColor: '#007AFF' } }
243
+ ```
244
+
245
+ Modal screens:
246
+ ```tsx
247
+ // Using addModal - automatically sets stackPresentation: 'modal'
248
+ const stack = new NavigationStack()
249
+ .addModal('/auth', AuthScreen, {
250
+ header: { title: 'Sign In' }
251
+ })
252
+ .addModal('/settings', SettingsScreen, {
253
+ header: { title: 'Settings' }
254
+ });
255
+
256
+ // Equivalent to using addScreen with explicit modal presentation
257
+ const stack = new NavigationStack()
258
+ .addScreen('/auth', AuthScreen, {
259
+ stackPresentation: 'modal',
260
+ header: { title: 'Sign In' }
261
+ });
262
+ ```
263
+
264
+ Paths, params and query
265
+
266
+ - Paths use path-to-regexp under the hood. Examples:
267
+ - `/users/:userId`
268
+ - `/orders/:year/:month`
269
+ - Params are exposed via `useParams()`; query params via `useQueryParams()` and are parsed with query-string.
270
+ - When you call `router.navigate('/users/123?tab=posts')`, your screen receives `{ userId: '123' }` as params and `{ tab: 'posts' }` as query.
271
+
272
+ ### Controllers: delay screen presentation and pass props
273
+
274
+ You can attach a controller to a route to perform checks or async work before the screen is shown. If a controller is present, the screen is NOT pushed until the controller calls `present(passProps)`.
275
+
276
+ Definition:
277
+
278
+ ```ts
279
+ import { createController } from '@sigmela/router';
280
+
281
+ type ProductParams = { productId: string };
282
+ type ProductQuery = { coupon?: string };
283
+
284
+ export const ProductController = createController<ProductParams, ProductQuery>((input, present) => {
285
+ // input.params and input.query are typed
286
+ // Do any sync/async work here (auth, data prefetch, A/B logic, etc.)
287
+ setTimeout(() => {
288
+ present({ preloadedTitle: 'From controller' }); // props passed to the screen
289
+ }, 300);
290
+ });
291
+ ```
292
+
293
+ Attach to a route:
294
+
295
+ ```ts
296
+ new NavigationStack()
297
+ .addScreen('/catalog/products/:productId', {
298
+ controller: ProductController,
299
+ component: ProductScreen,
300
+ }, {
301
+ header: { title: 'Product' },
302
+ });
303
+ ```
304
+
305
+ In your screen you can receive `passProps` from the controller alongside route params/query:
306
+
307
+ ```tsx
308
+ type ProductScreenProps = { preloadedTitle?: string };
309
+
310
+ function ProductScreen(props: ProductScreenProps) {
311
+ const { productId } = useParams<ProductParams>();
312
+ const { coupon } = useQueryParams<ProductQuery>();
313
+ return <Text>{props.preloadedTitle} #{productId} coupon={coupon ?? '—'}</Text>;
314
+ }
315
+ ```
316
+
317
+ Notes:
318
+ - `navigate()` and `replace()` both respect controllers.
319
+ - If the controller never calls `present()`, the screen will not be shown (useful for redirects).
320
+ - Props passed to `present()` are injected into the route component as regular props.
321
+
322
+ Behavior highlights (verified by tests)
323
+
324
+ - Initial seeding: the first screen of the active tab (or root stack) is pushed automatically.
325
+ - Duplicate navigate to the same top screen with the same params is ignored.
326
+ - goBack pops from the global stack first (if any), then from the current tab’s stack, then from the root stack; seed screens are protected.
327
+ - Navigating to a route inside a tab switches the active tab and seeds it if needed.
328
+ - setRoot switches between TabBar and NavigationStack, applies an optional transition, rebuilds the registry, and reseeds the new root; subscribers to `subscribeRoot` are notified.
329
+ - replace updates old/new stack slices atomically to avoid stale entries and keeps per-stack updates O(1) [[memory:6631860]].
330
+
331
+ TypeScript
332
+
333
+ Helpful exports:
334
+ - Types: `TabConfig`, `TabBarConfig`, `NavigationProps`, `NavigationAppearance`, `HistoryItem`
335
+ - Components: `Navigation`, `StackRenderer`, `TabBar`
336
+ - Hooks: `useRouter`, `useCurrentRoute`, `useParams`, `useQueryParams`
337
+ - Core classes: `Router`, `NavigationStack`
338
+
339
+ Requirements
340
+
341
+ - React 18+
342
+ - React Native (with react-native-screens)
343
+
344
+ License
345
+
346
+ MIT
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+
3
+ import { ScreenStack, ScreenStackItem as RNNScreenStackItem } from 'react-native-screens';
4
+ import { memo, useCallback, useEffect, useState, useSyncExternalStore } from 'react';
5
+ import { RenderTabBar } from "./TabBar/RenderTabBar.js";
6
+ import { ScreenStackItem } from "./ScreenStackItem.js";
7
+ import { RouterContext } from "./RouterContext.js";
8
+ import { StyleSheet } from 'react-native';
9
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
10
+ const EMPTY_HISTORY = [];
11
+ function useStackHistory(router, stackId) {
12
+ const subscribe = useCallback(cb => stackId ? router.subscribeStack(stackId, cb) : () => {}, [router, stackId]);
13
+ const get = useCallback(() => stackId ? router.getStackHistory(stackId) : EMPTY_HISTORY, [router, stackId]);
14
+ return useSyncExternalStore(subscribe, get, get);
15
+ }
16
+ export const Navigation = /*#__PURE__*/memo(({
17
+ router,
18
+ appearance
19
+ }) => {
20
+ const [root, setRoot] = useState(() => ({
21
+ hasTabBar: router.hasTabBar(),
22
+ rootId: router.getRootStackId()
23
+ }));
24
+ useEffect(() => {
25
+ return router.subscribeRoot(() => {
26
+ setRoot({
27
+ hasTabBar: router.hasTabBar(),
28
+ rootId: router.getRootStackId()
29
+ });
30
+ });
31
+ }, [router]);
32
+ const {
33
+ hasTabBar,
34
+ rootId
35
+ } = root;
36
+ const rootTransition = router.getRootTransition();
37
+ const globalId = router.getGlobalStackId();
38
+ const rootItems = useStackHistory(router, rootId);
39
+ const globalItems = useStackHistory(router, globalId);
40
+ return /*#__PURE__*/_jsx(RouterContext.Provider, {
41
+ value: router,
42
+ children: /*#__PURE__*/_jsxs(ScreenStack, {
43
+ style: styles.flex,
44
+ children: [hasTabBar && /*#__PURE__*/_jsx(RNNScreenStackItem, {
45
+ screenId: "root-tabbar",
46
+ headerConfig: {
47
+ hidden: true
48
+ },
49
+ style: styles.flex,
50
+ stackAnimation: rootTransition,
51
+ children: /*#__PURE__*/_jsx(RenderTabBar, {
52
+ tabBar: router.tabBar,
53
+ appearance: appearance?.tabBar
54
+ })
55
+ }), rootItems.map(item => /*#__PURE__*/_jsx(ScreenStackItem, {
56
+ stackId: rootId,
57
+ item: item,
58
+ stackAnimation: rootTransition,
59
+ screenStyle: appearance?.screenStyle,
60
+ headerAppearance: appearance?.header
61
+ }, item.key)), globalItems.map(item => /*#__PURE__*/_jsx(ScreenStackItem, {
62
+ stackId: globalId,
63
+ item: item,
64
+ screenStyle: appearance?.screenStyle,
65
+ headerAppearance: appearance?.header
66
+ }, item.key))]
67
+ })
68
+ });
69
+ });
70
+ const styles = StyleSheet.create({
71
+ flex: {
72
+ flex: 1
73
+ }
74
+ });
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+
3
+ import { nanoid } from 'nanoid/non-secure';
4
+ import { match } from 'path-to-regexp';
5
+ export class NavigationStack {
6
+ routes = [];
7
+
8
+ // Overloads
9
+
10
+ constructor(idOrOptions, maybeOptions) {
11
+ if (typeof idOrOptions === 'string') {
12
+ this.stackId = idOrOptions ?? `stack-${nanoid()}`;
13
+ this.defaultOptions = maybeOptions;
14
+ } else {
15
+ this.stackId = `stack-${nanoid()}`;
16
+ this.defaultOptions = idOrOptions;
17
+ }
18
+ }
19
+ getId() {
20
+ return this.stackId;
21
+ }
22
+ addScreen(path, mixedComponent, options) {
23
+ const {
24
+ component,
25
+ controller
26
+ } = this.extractComponent(mixedComponent);
27
+ const routeId = `${this.stackId}-route-${this.routes.length}`;
28
+ const matcher = match(path);
29
+ this.routes.push({
30
+ routeId,
31
+ path,
32
+ match: p => {
33
+ const result = matcher(p);
34
+ return result ? {
35
+ params: result.params ?? {}
36
+ } : false;
37
+ },
38
+ component,
39
+ controller,
40
+ options
41
+ });
42
+ return this;
43
+ }
44
+ addModal(path, mixedComponent, options) {
45
+ return this.addScreen(path, mixedComponent, {
46
+ ...options,
47
+ stackPresentation: 'modal'
48
+ });
49
+ }
50
+ getRoutes() {
51
+ return this.routes.slice();
52
+ }
53
+ getFirstRoute() {
54
+ return this.routes[0];
55
+ }
56
+ getDefaultOptions() {
57
+ return this.defaultOptions;
58
+ }
59
+ extractComponent(component) {
60
+ const componentWithController = component;
61
+ if (componentWithController?.component) {
62
+ return {
63
+ controller: componentWithController.controller,
64
+ component: componentWithController.component
65
+ };
66
+ }
67
+ return {
68
+ component: component,
69
+ controller: undefined
70
+ };
71
+ }
72
+ }