@sigmela/router 0.1.3 โ†’ 0.2.0

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 (52) hide show
  1. package/README.md +177 -833
  2. package/lib/module/Navigation.js +1 -10
  3. package/lib/module/NavigationStack.js +168 -19
  4. package/lib/module/Router.js +1503 -502
  5. package/lib/module/RouterContext.js +1 -1
  6. package/lib/module/ScreenStack/ScreenStack.web.js +343 -117
  7. package/lib/module/ScreenStack/ScreenStackContext.js +15 -0
  8. package/lib/module/ScreenStack/animationHelpers.js +72 -0
  9. package/lib/module/ScreenStackItem/ScreenStackItem.js +2 -1
  10. package/lib/module/ScreenStackItem/ScreenStackItem.web.js +76 -16
  11. package/lib/module/ScreenStackSheetItem/ScreenStackSheetItem.native.js +2 -1
  12. package/lib/module/ScreenStackSheetItem/ScreenStackSheetItem.web.js +1 -1
  13. package/lib/module/SplitView/RenderSplitView.native.js +85 -0
  14. package/lib/module/SplitView/RenderSplitView.web.js +79 -0
  15. package/lib/module/SplitView/SplitView.js +89 -0
  16. package/lib/module/SplitView/SplitViewContext.js +4 -0
  17. package/lib/module/SplitView/index.js +5 -0
  18. package/lib/module/SplitView/useSplitView.js +11 -0
  19. package/lib/module/StackRenderer.js +4 -2
  20. package/lib/module/TabBar/RenderTabBar.native.js +118 -33
  21. package/lib/module/TabBar/RenderTabBar.web.js +52 -47
  22. package/lib/module/TabBar/TabBar.js +117 -3
  23. package/lib/module/TabBar/index.js +4 -1
  24. package/lib/module/TabBar/useTabBarHeight.js +22 -0
  25. package/lib/module/index.js +3 -4
  26. package/lib/module/navigationNode.js +3 -0
  27. package/lib/module/styles.css +693 -28
  28. package/lib/typescript/src/NavigationStack.d.ts +25 -13
  29. package/lib/typescript/src/Router.d.ts +147 -34
  30. package/lib/typescript/src/RouterContext.d.ts +1 -1
  31. package/lib/typescript/src/ScreenStack/ScreenStack.web.d.ts +0 -2
  32. package/lib/typescript/src/ScreenStack/ScreenStackContext.d.ts +22 -0
  33. package/lib/typescript/src/ScreenStack/animationHelpers.d.ts +6 -0
  34. package/lib/typescript/src/ScreenStackItem/ScreenStackItem.types.d.ts +5 -1
  35. package/lib/typescript/src/ScreenStackItem/ScreenStackItem.web.d.ts +1 -1
  36. package/lib/typescript/src/SplitView/RenderSplitView.native.d.ts +8 -0
  37. package/lib/typescript/src/SplitView/RenderSplitView.web.d.ts +8 -0
  38. package/lib/typescript/src/SplitView/SplitView.d.ts +31 -0
  39. package/lib/typescript/src/SplitView/SplitViewContext.d.ts +3 -0
  40. package/lib/typescript/src/SplitView/index.d.ts +5 -0
  41. package/lib/typescript/src/SplitView/useSplitView.d.ts +2 -0
  42. package/lib/typescript/src/StackRenderer.d.ts +2 -1
  43. package/lib/typescript/src/TabBar/TabBar.d.ts +27 -3
  44. package/lib/typescript/src/TabBar/index.d.ts +3 -0
  45. package/lib/typescript/src/TabBar/useTabBarHeight.d.ts +18 -0
  46. package/lib/typescript/src/createController.d.ts +1 -0
  47. package/lib/typescript/src/index.d.ts +4 -3
  48. package/lib/typescript/src/navigationNode.d.ts +41 -0
  49. package/lib/typescript/src/types.d.ts +21 -32
  50. package/package.json +6 -5
  51. package/lib/module/web/TransitionStack.js +0 -227
  52. package/lib/typescript/src/web/TransitionStack.d.ts +0 -21
package/README.md CHANGED
@@ -1,57 +1,55 @@
1
- # React Native Router
1
+ # Sigmela Router (`@sigmela/router`)
2
2
 
3
- Modern, lightweight, and type-safe navigation for React Native and Web built on top of react-native-screens. Predictable stack-based architecture with tabs, global modals, sheets, controllers, and full Web History API integration.
3
+ Modern, lightweight navigation for **React Native** and **Web**, built on top of [`react-native-screens`](https://github.com/software-mansion/react-native-screens).
4
+
5
+ This library is **URL-first**: you navigate by **paths** (`/users/42?tab=posts`), and the router derives `params` and `query` for screens.
4
6
 
5
7
  ## Features
6
8
 
7
- - ๐Ÿชถ **Lightweight**: minimal dependencies, small bundle size
8
- - โšก **Fast & performant**: optimized for performance with minimal re-renders
9
- - ๐Ÿ”— **URL-first navigation**: path as source of truth, params in URL
10
- - ๐Ÿ›ก๏ธ **Type safety**: typed `useParams` and `useQueryParams`
11
- - โ›“๏ธ **Chainable API**: `new NavigationStack().addScreen('/users/:id', User)`
12
- - ๐Ÿ“ฑ **Sheets & modals**: native presentation styles including iOS/Android sheets
13
- - ๐ŸŽฏ **Bottom tabs**: native and web renderers, custom tab bars
14
- - ๐ŸŒ **Global stack**: modals and overlays on top of main navigation
15
- - ๐ŸŽฎ **Controllers**: async navigation with guards and data prefetch
16
- - ๐ŸŒ **Web History API**: full synchronization with pushState/replaceState/popstate
17
- - ๐Ÿ”— **Deep linking**: automatic stack construction from nested URLs
18
- - ๐Ÿ”„ **Dynamic root**: switch between stacks at runtime (auth flows)
19
- - ๐ŸŽจ **Appearance control**: customize tabs, headers and screens
20
-
21
- ## ๐Ÿ“ฆ Installation
9
+ - **Stacks**: predictable stack-based navigation
10
+ - **Tabs**: `TabBar` with native + web renderers (or custom tab bar)
11
+ - **Split view**: master/details navigation (`SplitView`)
12
+ - **Modals & sheets**: via `stackPresentation` (`modal`, `sheet`, โ€ฆ)
13
+ - **Controllers**: async/guarded navigation (only present when ready)
14
+ - **Web History integration**: keeps Router state in sync with `pushState`, `replaceState`, `popstate`
15
+ - **Dynamic root**: swap root navigation tree at runtime (`router.setRoot`)
16
+ - **Type-safe hooks**: `useParams`, `useQueryParams`, `useRoute`, `useCurrentRoute`
17
+
18
+ ## Installation
22
19
 
23
20
  ```bash
24
- yarn add @sigmela/router @sigmela/native-sheet react-native-screens
25
- # or
26
- npm i @sigmela/router @sigmela/native-sheet react-native-screens
21
+ yarn add @sigmela/router react-native-screens
22
+ # optional (required only if you use sheet presentation)
23
+ yarn add @sigmela/native-sheet
27
24
  ```
28
25
 
29
- ### ๐Ÿ“‹ Dependencies
26
+ ### Peer dependencies
30
27
 
31
- - `react-native-screens` >= 4.16.0
32
- - `@sigmela/native-sheet` >= 0.0.1 (optional, required for sheets)
33
- - `react` and `react-native` (matching your app versions)
28
+ - `react`
29
+ - `react-native`
30
+ - `react-native-screens` (>= `4.18.0`)
31
+ - `@sigmela/native-sheet` (>= `0.0.1`) โ€” only if you use sheets
34
32
 
35
- ### ๐ŸŒ Web CSS
33
+ ### Web CSS
36
34
 
37
- For web, import the bundled stylesheet once in your entry point:
35
+ On web you must import the bundled stylesheet **once**:
38
36
 
39
37
  ```ts
40
38
  import '@sigmela/router/styles.css';
41
39
  ```
42
40
 
43
- ## ๐Ÿš€ Quick Start
41
+ ## Quick start
44
42
 
45
- ### ๐Ÿ“„ Simple Stack
43
+ ### Simple stack
46
44
 
47
45
  ```tsx
48
46
  import {
47
+ Navigation,
49
48
  NavigationStack,
50
49
  Router,
51
- Navigation,
52
- useRouter,
53
50
  useParams,
54
51
  useQueryParams,
52
+ useRouter,
55
53
  } from '@sigmela/router';
56
54
 
57
55
  function HomeScreen() {
@@ -81,910 +79,256 @@ export default function App() {
81
79
  }
82
80
  ```
83
81
 
84
- ### ๐ŸŽฏ Tabs + Global Stack
82
+ ### Tabs
85
83
 
86
84
  ```tsx
87
- import { NavigationStack, Router, Navigation, TabBar } from '@sigmela/router';
85
+ import { Navigation, NavigationStack, Router, TabBar } from '@sigmela/router';
88
86
 
89
- const homeStack = new NavigationStack().addScreen('/', HomeScreen, {
90
- header: { title: 'Home' },
91
- });
87
+ const homeStack = new NavigationStack().addScreen('/', HomeScreen);
92
88
 
93
89
  const catalogStack = new NavigationStack()
94
- .addScreen('/catalog', CatalogScreen, { header: { title: 'Catalog' } })
95
- .addScreen('/catalog/products/:productId', ProductScreen, {
96
- header: { title: 'Product' },
97
- });
90
+ .addScreen('/catalog', CatalogScreen)
91
+ .addScreen('/catalog/products/:productId', ProductScreen);
92
+
93
+ const tabBar = new TabBar({ initialIndex: 0 })
94
+ .addTab({ key: 'home', stack: homeStack, title: 'Home' })
95
+ .addTab({ key: 'catalog', stack: catalogStack, title: 'Catalog' });
96
+
97
+ const router = new Router({ root: tabBar });
98
+
99
+ export default function App() {
100
+ return <Navigation router={router} />;
101
+ }
102
+ ```
103
+
104
+ ### Tabs wrapped by a root stack (like `example/src/navigation/stacks.ts`)
98
105
 
99
- const globalStack = new NavigationStack()
100
- .addModal('/auth', AuthScreen, { header: { title: 'Sign in' } });
106
+ In the example app, tabs are mounted as a **screen** inside a root `NavigationStack`. This lets you keep tab navigation plus define modals/overlays at the root level.
107
+
108
+ ```tsx
109
+ import { NavigationStack, TabBar } from '@sigmela/router';
110
+
111
+ const homeStack = new NavigationStack().addScreen('/', HomeScreen);
112
+ const catalogStack = new NavigationStack().addScreen('/catalog', CatalogScreen);
101
113
 
102
114
  const tabBar = new TabBar()
103
115
  .addTab({
104
116
  key: 'home',
105
117
  stack: homeStack,
106
118
  title: 'Home',
107
- icon: { sfSymbolName: 'house' }, // iOS SF Symbols
119
+ icon: require('./assets/home.png'),
108
120
  })
109
121
  .addTab({
110
122
  key: 'catalog',
111
123
  stack: catalogStack,
112
124
  title: 'Catalog',
113
- icon: { sfSymbolName: 'bag' },
125
+ icon: require('./assets/catalog.png'),
114
126
  });
115
127
 
116
- const router = new Router({ root: tabBar, global: globalStack });
117
-
118
- export default function App() {
119
- return <Navigation router={router} />;
120
- }
128
+ // Root stack hosts the tab bar AND top-level modals/overlays.
129
+ export const rootStack = new NavigationStack()
130
+ .addScreen('/', tabBar)
131
+ .addModal('/auth', AuthScreen, { header: { title: 'Login', hidden: true } })
132
+ .addModal('*?modal=promo', PromoModal);
121
133
  ```
122
134
 
123
- ## ๐Ÿงฉ Core Concepts
124
-
125
- ### ๐Ÿ“š Navigation Stack
135
+ ## Core concepts
126
136
 
127
- A `NavigationStack` is a container for screens. Stacks can be used as the root, inside tabs, or as a global overlay.
137
+ ### `NavigationStack`
128
138
 
129
- ```tsx
130
- const stack = new NavigationStack()
131
- .addScreen('/path', Component, options)
132
- .addModal('/modal', ModalComponent, options)
133
- .addSheet('/sheet', SheetComponent, options);
134
- ```
139
+ A `NavigationStack` defines a set of routes and how to match them.
135
140
 
136
- **Constructor overloads:**
137
141
  ```tsx
138
- new NavigationStack()
139
- new NavigationStack(id: string)
140
- new NavigationStack(defaultOptions: ScreenOptions)
141
- new NavigationStack(id: string, defaultOptions: ScreenOptions)
142
+ const stack = new NavigationStack({ header: { largeTitle: true } })
143
+ .addScreen('/feed', FeedScreen)
144
+ .addScreen('/feed/:id', FeedItemScreen)
145
+ .addModal('/auth', AuthScreen)
146
+ .addSheet('/settings', SettingsSheet);
142
147
  ```
143
148
 
144
- **Methods:**
145
- - `addScreen(path, component, options?)` โ€” add a regular screen
146
- - `addModal(path, component, options?)` โ€” add a modal (shorthand for `stackPresentation: 'modal'`)
147
- - `addSheet(path, component, options?)` โ€” add a sheet (shorthand for `stackPresentation: 'sheet'`)
148
- - `getId()` โ€” get the unique stack ID
149
- - `getRoutes()` โ€” get all routes in the stack
150
- - `getFirstRoute()` โ€” get the first route (used for initial seeding)
151
- - `getDefaultOptions()` โ€” get default options for all screens in this stack
149
+ Key methods:
150
+ - `addScreen(pathPattern, componentOrNode, options?)`
151
+ - `addModal(pathPattern, component, options?)` (shorthand for `stackPresentation: 'modal'`)
152
+ - `addSheet(pathPattern, component, options?)` (shorthand for `stackPresentation: 'sheet'`)
153
+ - `addStack(prefixOrStack, maybeStack?)` โ€” compose nested stacks under a prefix
152
154
 
153
- ### ๐Ÿงญ Router
155
+ ### `Router`
154
156
 
155
- The `Router` manages navigation state and history.
157
+ The `Router` holds navigation state and performs path matching.
156
158
 
157
- ```tsx
159
+ ```ts
158
160
  const router = new Router({
159
- root: tabBar | stack, // TabBar or NavigationStack
160
- global?: stack, // Optional global overlay stack
161
- screenOptions?: ScreenOptions // Global screen defaults
161
+ root, // NavigationNode (NavigationStack, TabBar, SplitView, ...)
162
+ screenOptions, // optional defaults
163
+ debug, // optional
162
164
  });
163
165
  ```
164
166
 
165
- **Navigation methods:**
166
- - `navigate(path: string)` โ€” push a new screen
167
- - `replace(path: string, dedupe?: boolean)` โ€” replace the top screen
168
- - `goBack()` โ€” pop the current screen
169
- - `onTabIndexChange(index: number)` โ€” switch to a specific tab
170
- - `setRoot(nextRoot, options?)` โ€” dynamically switch root (e.g., login โ†’ main app)
167
+ Navigation:
168
+ - `router.navigate(path)` โ€” push
169
+ - `router.replace(path, dedupe?)` โ€” replace top of the active stack
170
+ - `router.goBack()` โ€” pop top of the active stack
171
+ - `router.reset(path)` โ€” **web-only**: rebuild Router state as if app loaded at `path`
172
+ - `router.setRoot(nextRoot, { transition? })` โ€” swap root at runtime
171
173
 
172
- **State methods:**
173
- - `getVisibleRoute()` โ€” get currently visible route with params and query
174
- - `getState()` โ€” get the full router state
175
- - `subscribe(listener)` โ€” subscribe to all navigation changes
176
- - `subscribeStack(stackId, listener)` โ€” subscribe to specific stack changes
177
- - `subscribeRoot(listener)` โ€” subscribe to root structure changes
178
- - `subscribeActiveTab(listener)` โ€” subscribe to tab changes
174
+ State/subscriptions:
175
+ - `router.getState()` โ†’ `{ history: HistoryItem[] }`
176
+ - `router.getActiveRoute()` โ†’ `ActiveRoute | null`
177
+ - `router.subscribe(cb)` โ€” notify on any history change
178
+ - `router.subscribeStack(stackId, cb)` โ€” notify when a particular stack slice changes
179
+ - `router.subscribeRoot(cb)` โ€” notify when root is replaced via `setRoot`
180
+ - `router.getStackHistory(stackId)` โ€” slice of history for a stack
179
181
 
180
- **Stack history methods:**
181
- - `getStackHistory(stackId)` โ€” get history for a specific stack
182
- - `getRootStackId()` โ€” get the root stack ID (if root is a stack)
183
- - `getGlobalStackId()` โ€” get the global stack ID
184
- - `hasTabBar()` โ€” check if root is a TabBar
182
+ > Note: `router.getGlobalStackId()` exists but currently returns `undefined`.
185
183
 
186
- ### ๐Ÿ“‘ TabBar
184
+ ### `TabBar`
187
185
 
188
- A `TabBar` manages multiple stacks as tabs.
186
+ `TabBar` is a container node that renders one tab at a time.
189
187
 
190
188
  ```tsx
191
- const tabBar = new TabBar({ component?: CustomTabBarComponent })
192
- .addTab({
193
- key: string,
194
- stack?: NavigationStack,
195
- screen?: Component,
196
- title?: string,
197
- icon?: ImageSource | { sfSymbolName | imageSource | templateSource },
198
- selectedIcon?: ImageSource | { sfSymbolName | imageSource | templateSource },
199
- });
189
+ const tabBar = new TabBar({ component: CustomTabBar, initialIndex: 0 })
190
+ .addTab({ key: 'home', stack: homeStack, title: 'Home' })
191
+ .addTab({ key: 'search', screen: SearchScreen, title: 'Search' });
200
192
  ```
201
193
 
202
- **Methods:**
203
- - `addTab(config)` โ€” add a new tab
204
- - `setBadge(index, badge)` โ€” set badge text on a tab
205
- - `onIndexChange(index)` โ€” change active tab (usually called via router)
206
- - `getState()` โ€” get current tab state
207
- - `subscribe(listener)` โ€” subscribe to tab bar changes
194
+ Key methods:
195
+ - `addTab({ key, stack?, screen?, title?, icon?, selectedIcon?, ... })`
196
+ - `onIndexChange(index)` โ€” switch active tab
197
+ - `setBadge(index, badge | null)`
198
+ - `setTabBarConfig(partialConfig)`
199
+ - `getState()` and `subscribe(cb)`
208
200
 
209
- **Tab configuration:**
210
- Each tab can have either a `stack` (NavigationStack) or a single `screen` (Component). If using a stack, the tab will support full navigation within that tab.
201
+ Web behavior note:
202
+ - The built-in **web** tab bar renderer resets Router history on tab switch (to keep URL and Router state consistent) using `router.reset(firstRoutePath)`.
211
203
 
212
- ### โš™๏ธ Screen Options
204
+ ### `SplitView`
213
205
 
214
- Screen options control the appearance and behavior of individual screens:
206
+ `SplitView` renders **two stacks**: `primary` and `secondary`.
215
207
 
216
- ```tsx
217
- type ScreenOptions = {
218
- // Header configuration (react-native-screens)
219
- header?: {
220
- title?: string;
221
- hidden?: boolean;
222
- backTitle?: string;
223
- largeTitle?: boolean;
224
- // ... all ScreenStackHeaderConfigProps
225
- };
226
-
227
- // Presentation style
228
- stackPresentation?:
229
- | 'push'
230
- | 'modal'
231
- | 'transparentModal'
232
- | 'containedModal'
233
- | 'containedTransparentModal'
234
- | 'fullScreenModal'
235
- | 'formSheet'
236
- | 'pageSheet'
237
- | 'sheet';
238
-
239
- // Animation style
240
- stackAnimation?:
241
- | 'default'
242
- | 'fade'
243
- | 'flip'
244
- | 'none'
245
- | 'simple_push'
246
- | 'slide_from_right'
247
- | 'slide_from_left'
248
- | 'slide_from_bottom'
249
- | 'fade_from_bottom';
250
-
251
- // Android: convert modals to sheets
252
- convertModalToSheetForAndroid?: boolean;
253
-
254
- // Web helper for default tab icon rendering
255
- tabBarIcon?: string | { sfSymbolName?: string };
256
-
257
- // All other react-native-screens Screen props
258
- // ...
259
- };
260
- ```
261
-
262
- ### ๐Ÿ“ฑ Sheets
263
-
264
- Sheets are a special presentation style that shows content in a bottom sheet on both iOS and Android.
208
+ - On **native**, `secondary` overlays `primary` when it has at least one screen in its history.
209
+ - On **web**, the layout becomes side-by-side at a fixed breakpoint (currently `>= 640px` in CSS).
265
210
 
266
211
  ```tsx
267
- const stack = new NavigationStack()
268
- .addSheet('/settings', SettingsSheet, {
269
- header: { title: 'Settings' },
270
- });
212
+ import { NavigationStack, SplitView } from '@sigmela/router';
271
213
 
272
- // Or using addScreen with explicit option:
273
- const stack = new NavigationStack()
274
- .addScreen('/settings', SettingsSheet, {
275
- stackPresentation: 'sheet',
276
- header: { title: 'Settings' },
277
- });
278
-
279
- // Convert modals to sheets on Android:
280
- const stack = new NavigationStack()
281
- .addModal('/settings', SettingsSheet, {
282
- convertModalToSheetForAndroid: true,
283
- header: { title: 'Settings' },
284
- });
285
- ```
214
+ const master = new NavigationStack().addScreen('/', ThreadsScreen);
215
+ const detail = new NavigationStack().addScreen('/:threadId', ThreadScreen);
286
216
 
287
- **Sheet appearance:**
288
- ```tsx
289
- const appearance: NavigationAppearance = {
290
- sheet: {
291
- backgroundColor: '#fff',
292
- cornerRadius: 18,
293
- androidFullScreenTopInset: 40,
294
- },
295
- };
217
+ const splitView = new SplitView({
218
+ minWidth: 640, // currently not used by the web renderer; kept for API compatibility
219
+ primary: master,
220
+ secondary: detail,
221
+ primaryMaxWidth: 390,
222
+ });
296
223
 
297
- <Navigation router={router} appearance={appearance} />
224
+ const root = new NavigationStack().addScreen('/mail', splitView);
298
225
  ```
299
226
 
300
- ### ๐ŸŽฎ Controllers
227
+ ## Controllers
301
228
 
302
- Controllers run before a screen is presented. They're useful for:
303
- - Authentication guards
304
- - Data prefetching
305
- - Conditional redirects
306
- - Loading states
229
+ Controllers let you delay/guard navigation. A route can be registered as:
307
230
 
308
231
  ```tsx
309
232
  import { createController } from '@sigmela/router';
310
233
 
311
234
  const UserDetails = {
312
235
  component: UserDetailsScreen,
313
- controller: createController<
314
- { userId: string }, // Path params type
315
- { tab?: string } // Query params type
316
- >(async ({ params, query }, present) => {
317
- // Check auth
318
- const isAuthed = await checkAuth();
319
- if (!isAuthed) {
320
- router.navigate('/login');
321
- return;
322
- }
323
-
324
- // Prefetch data
325
- const userData = await fetchUser(params.userId);
326
-
327
- // Present screen with prefetched data
328
- present({ userData });
329
- }),
330
- };
331
-
332
- // Register with stack
333
- stack.addScreen('/users/:userId', UserDetails, {
334
- header: { title: 'User' }
335
- });
336
-
337
- // In the component, receive passProps
338
- function UserDetailsScreen({ userData }) {
339
- // userData is already loaded
340
- return <Text>{userData.name}</Text>;
341
- }
342
- ```
343
-
344
- **Controller signature:**
345
- ```tsx
346
- type Controller<TParams, TQuery> = (
347
- input: { params: TParams; query: TQuery },
348
- present: (passProps?: any) => void
349
- ) => void;
350
- ```
351
-
352
- Controllers receive `params` and `query` parsed from the URL. Call `present(passProps)` to show the screen, passing any data as props. If you don't call `present()`, the screen won't be shown (useful for redirects).
353
-
354
- ## ๐Ÿช Hooks
355
-
356
- ### useRouter
357
-
358
- Access the router instance:
359
-
360
- ```tsx
361
- function MyScreen() {
362
- const router = useRouter();
363
-
364
- return (
365
- <Button
366
- onPress={() => router.navigate('/details')}
367
- title="Go to details"
368
- />
369
- );
370
- }
371
- ```
372
-
373
- ### useParams
374
-
375
- Get typed path parameters:
376
-
377
- ```tsx
378
- function UserScreen() {
379
- const { userId } = useParams<{ userId: string }>();
380
- return <Text>User ID: {userId}</Text>;
381
- }
382
-
383
- // Route: /users/:userId
384
- // URL: /users/123
385
- // Result: { userId: '123' }
386
- ```
387
-
388
- ### useQueryParams
389
-
390
- Get typed query parameters:
391
-
392
- ```tsx
393
- function SearchScreen() {
394
- const { q, sort } = useQueryParams<{
395
- q?: string;
396
- sort?: 'asc' | 'desc'
397
- }>();
398
-
399
- return <Text>Query: {q}, Sort: {sort}</Text>;
400
- }
401
-
402
- // URL: /search?q=hello&sort=asc
403
- // Result: { q: 'hello', sort: 'asc' }
404
- ```
405
-
406
- ### useCurrentRoute
407
-
408
- Subscribe to the currently visible route:
409
-
410
- ```tsx
411
- function NavigationObserver() {
412
- const currentRoute = useCurrentRoute();
413
-
414
- useEffect(() => {
415
- console.log('Current route:', currentRoute?.path);
416
- }, [currentRoute]);
417
-
418
- return null;
419
- }
420
-
421
- // Returns: { scope, path, params, query, routeId, stackId, tabIndex? } | null
422
- ```
423
-
424
- ### useRoute
425
-
426
- Access the full route context for the current screen:
427
-
428
- ```tsx
429
- function MyScreen() {
430
- const route = useRoute();
431
-
432
- return (
433
- <View>
434
- <Text>Path: {route.path}</Text>
435
- <Text>Pattern: {route.pattern}</Text>
436
- <Text>Presentation: {route.presentation}</Text>
437
- </View>
438
- );
439
- }
440
-
441
- // Returns: { presentation, params, query, pattern, path }
442
- ```
443
-
444
- ### useTabBar
445
-
446
- Access the TabBar instance (only works inside a tab screen):
447
-
448
- ```tsx
449
- function HomeScreen() {
450
- const tabBar = useTabBar();
451
- const router = useRouter();
452
-
453
- const handleSwitchTab = () => {
454
- router.onTabIndexChange(1); // Switch to second tab
455
- };
456
-
457
- const handleSetBadge = () => {
458
- tabBar.setBadge(1, '5'); // Show badge on second tab
459
- };
460
-
461
- return (
462
- <View>
463
- <Button title="Switch Tab" onPress={handleSwitchTab} />
464
- <Button title="Set Badge" onPress={handleSetBadge} />
465
- </View>
466
- );
467
- }
468
- ```
469
-
470
- ## ๐Ÿš€ Advanced Features
471
-
472
- ### ๐Ÿ”„ Dynamic Root (Auth Flows)
473
-
474
- Switch between different root structures at runtime:
475
-
476
- ```tsx
477
- const authStack = new NavigationStack()
478
- .addScreen('/login', LoginScreen, { header: { title: 'Login' } });
479
-
480
- const mainTabBar = new TabBar()
481
- .addTab({ key: 'home', stack: homeStack, title: 'Home' });
482
-
483
- const router = new Router({ root: authStack });
484
-
485
- // Later, after login:
486
- function handleLogin() {
487
- router.setRoot(mainTabBar, { transition: 'fade' });
488
- }
489
-
490
- // Or logout:
491
- function handleLogout() {
492
- router.setRoot(authStack, { transition: 'fade' });
493
- }
494
- ```
495
-
496
- The `transition` option accepts any `stackAnimation` value (`'fade'`, `'slide_from_bottom'`, etc.).
497
-
498
- ### ๐ŸŽจ Custom Tab Bar
499
-
500
- Create a custom tab bar component:
501
-
502
- ```tsx
503
- import { TabBar, type TabBarProps } from '@sigmela/router';
504
-
505
- function CustomTabBar({ tabs, activeIndex, onTabPress }: TabBarProps) {
506
- return (
507
- <View style={styles.tabBar}>
508
- {tabs.map((tab, index) => (
509
- <TouchableOpacity
510
- key={tab.tabKey}
511
- onPress={() => onTabPress(index)}
512
- style={[
513
- styles.tab,
514
- index === activeIndex && styles.activeTab
515
- ]}
516
- >
517
- {tab.icon && <Image source={tab.icon} />}
518
- <Text style={styles.title}>{tab.title}</Text>
519
- {tab.badgeValue && <Text style={styles.badge}>{tab.badgeValue}</Text>}
520
- </TouchableOpacity>
521
- ))}
522
- </View>
523
- );
524
- }
525
-
526
- const tabBar = new TabBar({ component: CustomTabBar })
527
- .addTab({ key: 'home', stack: homeStack, title: 'Home' });
528
- ```
529
-
530
- ### ๐ŸŽจ Appearance Customization
531
-
532
- Customize the appearance of tabs, headers, and screens:
533
-
534
- ```tsx
535
- import type { NavigationAppearance } from '@sigmela/router';
536
-
537
- const appearance: NavigationAppearance = {
538
- // Tab bar styling
539
- tabBar: {
540
- backgroundColor: '#ffffff',
541
- iconColor: '#8e8e93',
542
- iconColorActive: '#007aff',
543
- badgeBackgroundColor: '#ff3b30',
544
- androidRippleColor: 'rgba(0,0,0,0.1)',
545
- androidActiveIndicatorEnabled: true,
546
- androidActiveIndicatorColor: '#007aff',
547
- iOSShadowColor: '#000',
548
- labelVisibilityMode: 'labeled',
549
- title: {
550
- fontSize: 11,
551
- fontFamily: 'System',
552
- fontWeight: '500',
553
- color: '#8e8e93',
554
- activeColor: '#007aff',
555
- },
556
- },
557
-
558
- // Header styling (applied to all headers)
559
- header: {
560
- backgroundColor: '#ffffff',
561
- tintColor: '#007aff',
562
- largeTitleColor: '#000',
563
- },
564
-
565
- // Screen styling
566
- screen: {
567
- backgroundColor: '#f2f2f7',
568
- },
569
-
570
- // Sheet styling
571
- sheet: {
572
- backgroundColor: '#ffffff',
573
- cornerRadius: 18,
574
- androidFullScreenTopInset: 40,
575
- },
576
- };
577
-
578
- export default function App() {
579
- return <Navigation router={router} appearance={appearance} />;
580
- }
581
- ```
582
-
583
- ### ๐Ÿ”” Tab Badges
584
-
585
- Show badges on tabs:
586
-
587
- ```tsx
588
- function SomeScreen() {
589
- const tabBar = useTabBar();
590
- const router = useRouter();
591
-
592
- useEffect(() => {
593
- // Show badge on second tab
594
- tabBar.setBadge(1, '3');
595
-
596
- // Clear badge
597
- // tabBar.setBadge(1, null);
598
- }, []);
599
- }
600
-
601
- // Or directly via router instance
602
- router.tabBar?.setBadge(1, '3');
603
- ```
604
-
605
- ### ๐ŸŽฌ Standalone Stack Renderer
606
-
607
- Render a specific stack anywhere in your component tree:
608
-
609
- ```tsx
610
- import { StackRenderer } from '@sigmela/router';
611
-
612
- function CustomLayout() {
613
- const myStack = new NavigationStack()
614
- .addScreen('/nested', NestedScreen);
615
-
616
- return (
617
- <View>
618
- <Header />
619
- <StackRenderer stack={myStack} appearance={appearance} />
620
- <Footer />
621
- </View>
622
- );
623
- }
624
- ```
625
-
626
- ## ๐ŸŒ Web Integration
627
-
628
- ### ๐Ÿ”„ History API Sync
629
-
630
- On web, the router automatically synchronizes with the browser's History API:
631
-
632
- - `router.navigate('/path')` โ†’ `history.pushState()`
633
- - `router.replace('/path')` โ†’ `history.replaceState()`
634
- - `router.goBack()` โ†’ `history.back()`
635
- - Browser back/forward buttons โ†’ `popstate` event โ†’ router state update
636
-
637
- You can also call `history.pushState()` or `history.replaceState()` directly, and the router will stay in sync.
638
-
639
- ### ๐Ÿ”— Deep Linking
640
-
641
- On initial load, the router automatically expands deep URLs into a stack chain:
642
-
643
- ```
644
- URL: /catalog/products/42
645
-
646
- Result stack:
647
- 1. /catalog (CatalogScreen)
648
- 2. /catalog/products/42 (ProductScreen)
649
- ```
650
-
651
- This only works for routes in the same stack. The router builds the longest possible chain of matching routes.
652
-
653
- ### โ™ป๏ธ Replace with Deduplication
654
-
655
- Avoid no-op replaces on web with the `dedupe` option:
656
-
657
- ```tsx
658
- // Won't replace if already on /catalog with same params
659
- router.replace('/catalog', true);
660
- ```
661
-
662
- This is useful when switching tabs on web to avoid creating duplicate history entries.
663
-
664
- ## ๐Ÿ›ก๏ธ TypeScript
665
-
666
- ### โœ… Type-Safe Params
667
-
668
- ```tsx
669
- import { useParams, useQueryParams } from '@sigmela/router';
670
-
671
- // Define your param types
672
- type UserParams = {
673
- userId: string;
674
- tab?: string;
675
- };
676
-
677
- type UserQuery = {
678
- ref?: string;
679
- highlight?: string;
680
- };
681
-
682
- function UserScreen() {
683
- const params = useParams<UserParams>();
684
- const query = useQueryParams<UserQuery>();
685
-
686
- // TypeScript knows:
687
- // params.userId is string
688
- // params.tab is string | undefined
689
- // query.ref is string | undefined
690
- }
691
- ```
692
-
693
- ### โœ… Type-Safe Controllers
694
-
695
- ```tsx
696
- import { createController } from '@sigmela/router';
697
-
698
- type ProductParams = { productId: string };
699
- type ProductQuery = { variant?: string };
700
-
701
- const ProductDetails = {
702
- component: ProductScreen,
703
- controller: createController<ProductParams, ProductQuery>(
236
+ controller: createController<{ userId: string }, { tab?: string }>(
704
237
  async ({ params, query }, present) => {
705
- // params.productId is typed as string
706
- // query.variant is typed as string | undefined
707
-
708
- const product = await fetchProduct(params.productId);
709
- present({ product });
238
+ const ok = await checkAuth();
239
+ if (!ok) {
240
+ router.replace('/login', true);
241
+ return;
242
+ }
243
+
244
+ const user = await fetchUser(params.userId);
245
+ present({ user, tab: query.tab });
710
246
  }
711
247
  ),
712
248
  };
713
- ```
714
-
715
- ## ๐Ÿ“– API Reference
716
-
717
- ### ๐Ÿงญ Router
718
-
719
- ```tsx
720
- class Router {
721
- constructor(config: {
722
- root: TabBar | NavigationStack;
723
- global?: NavigationStack;
724
- screenOptions?: ScreenOptions;
725
- });
726
-
727
- // Navigation
728
- navigate(path: string): void;
729
- replace(path: string, dedupe?: boolean): void;
730
- goBack(): void;
731
- onTabIndexChange(index: number): void;
732
- setRoot(nextRoot: TabBar | NavigationStack, options?: { transition?: RootTransition }): void;
733
-
734
- // State
735
- getState(): { history: HistoryItem[]; activeTabIndex?: number };
736
- getVisibleRoute(): VisibleRoute | null;
737
- getStackHistory(stackId: string): HistoryItem[];
738
- getRootStackId(): string | undefined;
739
- getGlobalStackId(): string | undefined;
740
- getActiveTabIndex(): number;
741
- hasTabBar(): boolean;
742
- getRootTransition(): RootTransition | undefined;
743
-
744
- // Subscriptions
745
- subscribe(listener: () => void): () => void;
746
- subscribeStack(stackId: string, listener: () => void): () => void;
747
- subscribeActiveTab(listener: () => void): () => void;
748
- subscribeRoot(listener: () => void): () => void;
749
-
750
- // Sheet management (internal)
751
- registerSheetDismisser(key: string, dismisser: () => void): void;
752
- unregisterSheetDismisser(key: string): void;
753
- }
754
- ```
755
249
 
756
- ### ๐Ÿ“š NavigationStack
757
-
758
- ```tsx
759
- class NavigationStack {
760
- constructor();
761
- constructor(id: string);
762
- constructor(defaultOptions: ScreenOptions);
763
- constructor(id: string, defaultOptions: ScreenOptions);
764
-
765
- addScreen(
766
- path: string,
767
- component: Component | { component: Component; controller?: Controller },
768
- options?: ScreenOptions
769
- ): NavigationStack;
770
-
771
- addModal(
772
- path: string,
773
- component: Component | { component: Component; controller?: Controller },
774
- options?: ScreenOptions
775
- ): NavigationStack;
776
-
777
- addSheet(
778
- path: string,
779
- component: Component | { component: Component; controller?: Controller },
780
- options?: ScreenOptions
781
- ): NavigationStack;
782
-
783
- getId(): string;
784
- getRoutes(): BuiltRoute[];
785
- getFirstRoute(): BuiltRoute | undefined;
786
- getDefaultOptions(): ScreenOptions | undefined;
787
- }
250
+ stack.addScreen('/users/:userId', UserDetails);
788
251
  ```
789
252
 
790
- ### ๐Ÿ“‘ TabBar
253
+ If you never call `present()`, the screen is not pushed/replaced.
791
254
 
792
- ```tsx
793
- type TabConfig = {
794
- key: string;
795
- stack?: NavigationStack;
796
- screen?: Component;
797
- title?: string;
798
- icon?: ImageSource | {
799
- sfSymbolName?: string;
800
- imageSource?: ImageSource;
801
- templateSource?: ImageSource;
802
- };
803
- selectedIcon?: ImageSource | {
804
- sfSymbolName?: string;
805
- imageSource?: ImageSource;
806
- templateSource?: ImageSource;
807
- };
808
- };
255
+ ## Hooks
809
256
 
810
- class TabBar {
811
- constructor(config?: { component?: ComponentType<TabBarProps> });
257
+ ### `useRouter()`
812
258
 
813
- addTab(config: TabConfig): TabBar;
814
- setBadge(tabIndex: number, badge: string | null): void;
815
- onIndexChange(index: number): void;
816
- getState(): { tabs: InternalTabItem[]; index: number; config: TabBarConfig };
817
- subscribe(listener: () => void): () => void;
818
- }
819
- ```
259
+ Access the router instance.
820
260
 
821
- ### ๐Ÿ“ Types
261
+ ### `useCurrentRoute()`
822
262
 
823
- ```tsx
824
- type ScreenOptions = {
825
- header?: ScreenStackHeaderConfigProps;
826
- stackPresentation?: StackPresentationTypes;
827
- stackAnimation?: 'default' | 'fade' | 'flip' | 'none' | 'simple_push'
828
- | 'slide_from_right' | 'slide_from_left' | 'slide_from_bottom' | 'fade_from_bottom';
829
- convertModalToSheetForAndroid?: boolean;
830
- tabBarIcon?: string | { sfSymbolName?: string };
831
- // ... all react-native-screens Screen props
832
- };
263
+ Subscribes to `router.getActiveRoute()`.
833
264
 
834
- type NavigationAppearance = {
835
- tabBar?: {
836
- backgroundColor?: ColorValue;
837
- badgeBackgroundColor?: ColorValue;
838
- iconColor?: ColorValue;
839
- iconColorActive?: ColorValue;
840
- androidActiveIndicatorEnabled?: boolean;
841
- androidActiveIndicatorColor?: ColorValue;
842
- androidRippleColor?: ColorValue;
843
- labelVisibilityMode?: 'labeled' | 'hidden' | 'selected';
844
- iOSShadowColor?: ColorValue;
845
- title?: {
846
- fontFamily?: string;
847
- fontSize?: number;
848
- fontWeight?: string;
849
- fontStyle?: string;
850
- color?: string;
851
- activeColor?: string;
852
- };
853
- };
854
- screen?: StyleProp<ViewStyle>;
855
- header?: ScreenStackHeaderConfigProps;
856
- sheet?: {
857
- backgroundColor?: ColorValue;
858
- cornerRadius?: number;
859
- androidFullScreenTopInset?: number;
860
- };
861
- };
265
+ Returns `ActiveRoute | null` (shape from `src/types.ts`):
862
266
 
863
- type VisibleRoute = {
267
+ ```ts
268
+ type ActiveRoute = {
864
269
  routeId: string;
865
270
  stackId?: string;
866
271
  tabIndex?: number;
867
- scope: 'global' | 'tab' | 'root';
868
272
  path?: string;
869
273
  params?: Record<string, unknown>;
870
274
  query?: Record<string, unknown>;
871
275
  } | null;
872
-
873
- type TabBarProps = {
874
- tabs: InternalTabItem[];
875
- activeIndex: number;
876
- onTabPress: (index: number) => void;
877
- };
878
-
879
- type Controller<TParams, TQuery> = (
880
- input: { params: TParams; query: TQuery },
881
- present: (passProps?: any) => void
882
- ) => void;
883
276
  ```
884
277
 
885
- ## ๐Ÿ’ก Examples
278
+ ### `useParams<T>()` / `useQueryParams<T>()`
886
279
 
887
- ### ๐Ÿ” Full Example with Auth Flow
280
+ Returns params/query for the **current screen** (from route context).
888
281
 
889
- ```tsx
890
- import {
891
- NavigationStack,
892
- Router,
893
- Navigation,
894
- TabBar,
895
- createController,
896
- useRouter,
897
- useParams,
898
- } from '@sigmela/router';
899
-
900
- // Auth check controller
901
- const requireAuth = createController(async (input, present) => {
902
- const isAuthed = await checkAuth();
903
- if (!isAuthed) {
904
- router.navigate('/login');
905
- return;
906
- }
907
- present();
908
- });
282
+ ### `useRoute()`
909
283
 
910
- // Stacks
911
- const homeStack = new NavigationStack()
912
- .addScreen('/', { component: HomeScreen, controller: requireAuth }, {
913
- header: { title: 'Home' },
914
- });
915
-
916
- const profileStack = new NavigationStack()
917
- .addScreen('/profile', { component: ProfileScreen, controller: requireAuth }, {
918
- header: { title: 'Profile' },
919
- })
920
- .addScreen('/profile/settings', SettingsScreen, {
921
- header: { title: 'Settings' },
922
- });
284
+ Returns route-local context for the current screen:
923
285
 
924
- const authStack = new NavigationStack()
925
- .addScreen('/login', LoginScreen, {
926
- header: { title: 'Login', hidden: true },
927
- });
286
+ ```ts
287
+ type RouteLocalContextValue = {
288
+ presentation: StackPresentationTypes;
289
+ params?: Record<string, unknown>;
290
+ query?: Record<string, unknown>;
291
+ pattern?: string;
292
+ path?: string;
293
+ };
294
+ ```
928
295
 
929
- const mainTabs = new TabBar()
930
- .addTab({ key: 'home', stack: homeStack, title: 'Home', icon: { sfSymbolName: 'house' } })
931
- .addTab({ key: 'profile', stack: profileStack, title: 'Profile', icon: { sfSymbolName: 'person' } });
296
+ ### `useTabBar()`
932
297
 
933
- const globalStack = new NavigationStack()
934
- .addSheet('/settings-modal', SettingsModalScreen, {
935
- convertModalToSheetForAndroid: true,
936
- header: { title: 'Quick Settings' },
937
- });
298
+ Returns the nearest `TabBar` from context (only inside tab screens).
938
299
 
939
- // Router
940
- const router = new Router({
941
- root: authStack, // Start with auth
942
- global: globalStack,
943
- });
300
+ ```tsx
301
+ import { useTabBar } from '@sigmela/router';
944
302
 
945
- // Login screen
946
- function LoginScreen() {
947
- const router = useRouter();
948
-
949
- const handleLogin = async () => {
950
- await login();
951
- router.setRoot(mainTabs, { transition: 'fade' });
952
- };
953
-
954
- return <Button title="Login" onPress={handleLogin} />;
955
- }
303
+ function ScreenInsideTabs() {
304
+ const tabBar = useTabBar();
956
305
 
957
- // App
958
- export default function App() {
959
- return <Navigation router={router} appearance={appearance} />;
306
+ return (
307
+ <Button
308
+ title="Go to second tab"
309
+ onPress={() => tabBar.onIndexChange(1)}
310
+ />
311
+ );
960
312
  }
961
313
  ```
962
314
 
963
- ## ๐Ÿ’ก Tips & Best Practices
964
-
965
- 1. ๐ŸŽฏ **Use path-based navigation**: Always navigate with paths (`router.navigate('/path')`) rather than imperative methods. This keeps web and native in sync.
315
+ ## Web integration
966
316
 
967
- 2. ๐Ÿ›ก๏ธ **Type your params**: Use TypeScript generics with `useParams` and `useQueryParams` for type safety.
317
+ ### History API syncing
968
318
 
969
- 3. ๐ŸŽฎ **Controllers for guards**: Use controllers for auth checks, data prefetching, and conditional navigation instead of doing it in the component.
319
+ On web, Router integrates with the browser History API using custom events:
970
320
 
971
- 4. ๐ŸŒ **Global stack for overlays**: Use the global stack for modals/sheets that should appear on top of everything (auth, alerts, etc.).
321
+ - `router.navigate('/x')` writes `history.pushState({ __srPath: ... })`
322
+ - `router.replace('/x')` writes `history.replaceState({ __srPath: ... })`
323
+ - Browser back/forward triggers `popstate` and Router updates its state accordingly
972
324
 
973
- 5. ๐Ÿท๏ธ **Stack IDs**: Optionally provide custom stack IDs for debugging: `new NavigationStack('my-stack-id')`.
325
+ Important behavioral detail:
326
+ - `router.goBack()` **does not call** `history.back()`. It pops Router state and updates the URL via `replaceState` (so it doesnโ€™t grow/rewind the browser stack).
974
327
 
975
- 6. โš™๏ธ **Default options**: Set default options at the stack level to avoid repetition:
976
- ```tsx
977
- const stack = new NavigationStack({ header: { largeTitle: true } });
978
- ```
328
+ ### `syncWithUrl: false`
979
329
 
980
- 7. ๐ŸŒ **Web CSS**: Don't forget to import `@sigmela/router/styles.css` in your web entry point.
330
+ If a route has `screenOptions.syncWithUrl = false`, Router stores the โ€œrealโ€ router path in `history.state.__srPath` while keeping the visible URL unchanged.
981
331
 
982
- 8. ๐Ÿ“ฑ **Sheet vs Modal**: Use `addSheet()` for bottom sheets, `addModal()` for full-screen modals. On Android, use `convertModalToSheetForAndroid: true` to convert modals to sheets automatically.
983
-
984
- ## ๐Ÿ“„ License
332
+ ## License
985
333
 
986
334
  MIT
987
-
988
- ---
989
-
990
- Built with โค๏ธ by [Sigmela](https://github.com/sigmela)