@sigmela/router 0.0.17 → 0.1.2

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/README.md CHANGED
@@ -1,33 +1,49 @@
1
- # React Native Router
1
+ # React Native Router
2
2
 
3
- Modern, predictable navigation for React Native and Web built on top of react-native-screens. Simple class-based stacks, optional bottom tabs, global modals, typed URL params, and first-class web History API support.
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.
4
4
 
5
- Features
6
- - Simple, chainable API: `new NavigationStack().addScreen('/users/:id', User)`
7
- - Bottom tab bar with native and web renderers; supports custom tab bars
8
- - Global stack for modals/overlays on top of root/tabs
9
- - URL-first navigation: navigate using path strings; typed `useParams` and `useQueryParams`
10
- - Works on web: integrates with pushState/replaceState/popstate and supports deep links
11
- - Appearance control for headers, screens, and tab bar
5
+ ## Features
6
+
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
12
22
 
13
- Installation
14
23
  ```bash
15
- yarn add @sigmela/router react-native-screens
24
+ yarn add @sigmela/router @sigmela/native-sheet react-native-screens
16
25
  # or
17
- npm i @sigmela/router react-native-screens
26
+ npm i @sigmela/router @sigmela/native-sheet react-native-screens
18
27
  ```
19
28
 
20
- Peer requirements
21
- - react-native-screens >= 4.16.0
22
- - react and react-native (versions matching your app)
29
+ ### 📋 Dependencies
30
+
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)
34
+
35
+ ### 🌐 Web CSS
36
+
37
+ For web, import the bundled stylesheet once in your entry point:
23
38
 
24
- Web CSS
25
- - Import the bundled stylesheet once in your web entry to enable transitions and default tab styles:
26
39
  ```ts
27
40
  import '@sigmela/router/styles.css';
28
41
  ```
29
42
 
30
- Quick start (single stack)
43
+ ## 🚀 Quick Start
44
+
45
+ ### 📄 Simple Stack
46
+
31
47
  ```tsx
32
48
  import {
33
49
  NavigationStack,
@@ -51,7 +67,7 @@ function HomeScreen() {
51
67
  function DetailsScreen() {
52
68
  const { id } = useParams<{ id: string }>();
53
69
  const { from } = useQueryParams<{ from?: string }>();
54
- return <Text>{`Details id=${id} from=${from ?? 'n/a'}`}</Text>;
70
+ return <Text>Details: id={id}, from={from ?? 'n/a'}</Text>;
55
71
  }
56
72
 
57
73
  const rootStack = new NavigationStack()
@@ -65,7 +81,8 @@ export default function App() {
65
81
  }
66
82
  ```
67
83
 
68
- Quick start (tabs + global stack)
84
+ ### ðŸŽŊ Tabs + Global Stack
85
+
69
86
  ```tsx
70
87
  import { NavigationStack, Router, Navigation, TabBar } from '@sigmela/router';
71
88
 
@@ -79,17 +96,15 @@ const catalogStack = new NavigationStack()
79
96
  header: { title: 'Product' },
80
97
  });
81
98
 
82
- const globalStack = new NavigationStack().addModal('/auth', AuthScreen, {
83
- header: { title: 'Sign in' },
84
- });
99
+ const globalStack = new NavigationStack()
100
+ .addModal('/auth', AuthScreen, { header: { title: 'Sign in' } });
85
101
 
86
102
  const tabBar = new TabBar()
87
103
  .addTab({
88
104
  key: 'home',
89
105
  stack: homeStack,
90
106
  title: 'Home',
91
- // iOS: SF Symbols, Android/Web: image source
92
- icon: { sfSymbolName: 'house' },
107
+ icon: { sfSymbolName: 'house' }, // iOS SF Symbols
93
108
  })
94
109
  .addTab({
95
110
  key: 'catalog',
@@ -105,43 +120,458 @@ export default function App() {
105
120
  }
106
121
  ```
107
122
 
108
- Custom tab bar (optional)
123
+ ## ðŸ§Đ Core Concepts
124
+
125
+ ### 📚 Navigation Stack
126
+
127
+ A `NavigationStack` is a container for screens. Stacks can be used as the root, inside tabs, or as a global overlay.
128
+
129
+ ```tsx
130
+ const stack = new NavigationStack()
131
+ .addScreen('/path', Component, options)
132
+ .addModal('/modal', ModalComponent, options)
133
+ .addSheet('/sheet', SheetComponent, options);
134
+ ```
135
+
136
+ **Constructor overloads:**
137
+ ```tsx
138
+ new NavigationStack()
139
+ new NavigationStack(id: string)
140
+ new NavigationStack(defaultOptions: ScreenOptions)
141
+ new NavigationStack(id: string, defaultOptions: ScreenOptions)
142
+ ```
143
+
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
152
+
153
+ ### 🧭 Router
154
+
155
+ The `Router` manages navigation state and history.
156
+
157
+ ```tsx
158
+ const router = new Router({
159
+ root: tabBar | stack, // TabBar or NavigationStack
160
+ global?: stack, // Optional global overlay stack
161
+ screenOptions?: ScreenOptions // Global screen defaults
162
+ });
163
+ ```
164
+
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)
171
+
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
179
+
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
185
+
186
+ ### 📑 TabBar
187
+
188
+ A `TabBar` manages multiple stacks as tabs.
189
+
190
+ ```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
+ });
200
+ ```
201
+
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
208
+
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.
211
+
212
+ ### ⚙ïļ Screen Options
213
+
214
+ Screen options control the appearance and behavior of individual screens:
215
+
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.
265
+
266
+ ```tsx
267
+ const stack = new NavigationStack()
268
+ .addSheet('/settings', SettingsSheet, {
269
+ header: { title: 'Settings' },
270
+ });
271
+
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
+ ```
286
+
287
+ **Sheet appearance:**
288
+ ```tsx
289
+ const appearance: NavigationAppearance = {
290
+ sheet: {
291
+ backgroundColor: '#fff',
292
+ cornerRadius: 18,
293
+ androidFullScreenTopInset: 40,
294
+ },
295
+ };
296
+
297
+ <Navigation router={router} appearance={appearance} />
298
+ ```
299
+
300
+ ### ðŸŽŪ Controllers
301
+
302
+ Controllers run before a screen is presented. They're useful for:
303
+ - Authentication guards
304
+ - Data prefetching
305
+ - Conditional redirects
306
+ - Loading states
307
+
308
+ ```tsx
309
+ import { createController } from '@sigmela/router';
310
+
311
+ const UserDetails = {
312
+ 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
+
109
502
  ```tsx
110
503
  import { TabBar, type TabBarProps } from '@sigmela/router';
111
504
 
112
- function MyTabBar({ tabs, activeIndex, onTabPress }: TabBarProps) {
505
+ function CustomTabBar({ tabs, activeIndex, onTabPress }: TabBarProps) {
113
506
  return (
114
- <div className="my-tabs">
115
- {tabs.map((t, i) => (
116
- <button key={t.tabKey} onClick={() => onTabPress(i)} aria-pressed={i === activeIndex}>
117
- {t.title}
118
- </button>
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>
119
521
  ))}
120
- </div>
522
+ </View>
121
523
  );
122
524
  }
123
525
 
124
- const tabBar = new TabBar({ component: MyTabBar })
125
- .addTab({ key: 'home', stack: homeStack, title: 'Home' })
126
- .addTab({ key: 'catalog', stack: catalogStack, title: 'Catalog' });
526
+ const tabBar = new TabBar({ component: CustomTabBar })
527
+ .addTab({ key: 'home', stack: homeStack, title: 'Home' });
127
528
  ```
128
529
 
129
- Appearance
130
- Pass `appearance` to `Navigation` to style headers, screens, and the tab bar.
530
+ ### ðŸŽĻ Appearance Customization
531
+
532
+ Customize the appearance of tabs, headers, and screens:
533
+
131
534
  ```tsx
132
535
  import type { NavigationAppearance } from '@sigmela/router';
133
536
 
134
537
  const appearance: NavigationAppearance = {
538
+ // Tab bar styling
135
539
  tabBar: {
136
- backgroundColor: '#fff',
540
+ backgroundColor: '#ffffff',
137
541
  iconColor: '#8e8e93',
138
- iconColorActive: '#000',
139
- title: { fontSize: 11, color: '#555', activeColor: '#000' },
140
- // Android-only options:
542
+ iconColorActive: '#007aff',
543
+ badgeBackgroundColor: '#ff3b30',
141
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
+ },
142
556
  },
557
+
558
+ // Header styling (applied to all headers)
143
559
  header: {
144
- backgroundColor: '#fff',
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,
145
575
  },
146
576
  };
147
577
 
@@ -150,98 +580,411 @@ export default function App() {
150
580
  }
151
581
  ```
152
582
 
153
- API Reference
154
- - Classes
155
- - NavigationStack
156
- - `constructor(idOrOptions?: string | ScreenOptions, defaults?: ScreenOptions)`
157
- - `addScreen(path: string, component: Component | { component, controller? }, options?: ScreenOptions)`
158
- - `addModal(path: string, component: Component | { component, controller? }, options?: ScreenOptions)`
159
- - TabBar
160
- - `constructor(config?: { component?: ComponentType<TabBarProps> })`
161
- - `addTab({ key: string, stack?: NavigationStack, screen?: Component, title?: string, icon?: ImageSource | { sfSymbolName | imageSource | templateSource } })`
162
- - `setBadge(index: number, badge: string | null)`
163
- - Router
164
- - `constructor({ root: TabBar | NavigationStack, global?: NavigationStack, screenOptions?: ScreenOptions })`
165
- - `navigate(path: string)` — push a route (e.g. `/catalog/products/42?ref=home`)
166
- - `replace(path: string, dedupe?: boolean)` — replace top route; `dedupe` avoids no-op replaces on web
167
- - `goBack()` — pop a single screen within the active stack (or global)
168
- - `setRoot(nextRoot: TabBar | NavigationStack, options?: { transition?: ScreenOptions['stackAnimation'] })`
169
- - `getVisibleRoute()` — returns `{ scope, path, params, query, ... } | null`
170
-
171
- - Components
172
- - `Navigation` — the renderer. Props: `{ router: Router; appearance?: NavigationAppearance }`
173
-
174
- - Hooks
175
- - `useRouter()` — access the router instance
176
- - `useCurrentRoute()` — subscribe to the currently visible route
177
- - `useParams<T>()` — typed path params
178
- - `useQueryParams<T>()` — typed query params
179
- - `useRoute()` — raw route context `{ presentation, params, query, pattern, path }`
180
- - `useTabBar()` — access the current `TabBar` (inside a tab screen)
181
-
182
- - Utilities
183
- - `createController<TParams, TQuery>(controller)` — build controllers for guarded navigation
184
-
185
- - Types
186
- - `ScreenOptions` — subset of `react-native-screens` Screen props plus `{ header?, tabBarIcon? }`
187
- - `NavigationAppearance` — `{ tabBar?, screen?, header? }`
188
- - `TabBarProps` — props passed to a custom tab bar component
189
-
190
- Screen options
191
- - `header`: `ScreenStackHeaderConfigProps` (from react-native-screens). If `title` is falsy, the header is hidden.
192
- - `stackPresentation`: `'push' | 'modal' | ...'` (react-native-screens)
193
- - `stackAnimation`: `'slide_from_right' | 'fade' | ...'` (react-native-screens)
194
- - `tabBarIcon` (web helper): string or `{ sfSymbolName?: string }` for default web icon rendering
195
-
196
- Controllers (guarded/async navigation)
197
- Controllers run before a screen is presented. Call `present(passProps?)` when you're ready to show the screen. Useful for auth checks, data prefetch, or conditional redirects.
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
+
198
695
  ```tsx
199
696
  import { createController } from '@sigmela/router';
200
697
 
201
- const Details = {
202
- component: DetailsScreen,
203
- controller: createController<{ id: string }, { from?: string }>(async ({ params }, present) => {
204
- const isSignedIn = await auth.check();
205
- if (!isSignedIn) {
206
- router.navigate('/auth');
207
- return;
698
+ type ProductParams = { productId: string };
699
+ type ProductQuery = { variant?: string };
700
+
701
+ const ProductDetails = {
702
+ component: ProductScreen,
703
+ controller: createController<ProductParams, ProductQuery>(
704
+ 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 });
208
710
  }
209
- present({ fetched: await api.load(params.id) });
210
- }),
711
+ ),
211
712
  };
713
+ ```
714
+
715
+ ## 📖 API Reference
212
716
 
213
- new NavigationStack().addScreen('/details/:id', Details);
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
+ }
214
754
  ```
215
755
 
216
- Web behavior
217
- - On web, the router listens to `pushState`, `replaceState`, and `popstate`. You can navigate by calling `router.navigate('/path')` or by updating `window.history` yourself; the router will stay in sync.
218
- - Initial load deep links are expanded into a stack chain: `/a/b/c` seeds the stack with `/a` → `/a/b` → `/a/b/c` if those routes exist in the same stack.
219
- - `goBack()` pops within the active stack (or global). The router avoids creating duplicate entries when switching tabs by using `replace` under the hood in the web tab bar.
756
+ ### 📚 NavigationStack
220
757
 
221
- Root switching (auth flows)
222
- Switch between a login stack and the main tab bar at runtime. Optionally pass a transition for the change.
223
758
  ```tsx
224
- router.setRoot(loggedIn ? mainTabs : authStack, { transition: 'fade' });
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
+ }
225
788
  ```
226
789
 
227
- Badges and programmatic tab control
228
- ```ts
229
- // Show a badge on the second tab
230
- tabBar.setBadge(1, '3');
790
+ ### 📑 TabBar
791
+
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
+ };
809
+
810
+ class TabBar {
811
+ constructor(config?: { component?: ComponentType<TabBarProps> });
812
+
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
+ ```
820
+
821
+ ### 📝 Types
822
+
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
+ };
833
+
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
+ };
862
+
863
+ type VisibleRoute = {
864
+ routeId: string;
865
+ stackId?: string;
866
+ tabIndex?: number;
867
+ scope: 'global' | 'tab' | 'root';
868
+ path?: string;
869
+ params?: Record<string, unknown>;
870
+ query?: Record<string, unknown>;
871
+ } | 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
+ ```
884
+
885
+ ## ðŸ’Ą Examples
886
+
887
+ ### 🔐 Full Example with Auth Flow
888
+
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
+ });
909
+
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
+ });
923
+
924
+ const authStack = new NavigationStack()
925
+ .addScreen('/login', LoginScreen, {
926
+ header: { title: 'Login', hidden: true },
927
+ });
928
+
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' } });
932
+
933
+ const globalStack = new NavigationStack()
934
+ .addSheet('/settings-modal', SettingsModalScreen, {
935
+ convertModalToSheetForAndroid: true,
936
+ header: { title: 'Quick Settings' },
937
+ });
231
938
 
232
- // Switch active tab (e.g., from a screen)
233
- useRouter().onTabIndexChange(2);
939
+ // Router
940
+ const router = new Router({
941
+ root: authStack, // Start with auth
942
+ global: globalStack,
943
+ });
944
+
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
+ }
956
+
957
+ // App
958
+ export default function App() {
959
+ return <Navigation router={router} appearance={appearance} />;
960
+ }
234
961
  ```
235
962
 
236
- Example app
237
- - This repo contains an `example` app demonstrating tabs, stacks, and appearance.
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.
966
+
967
+ 2. ðŸ›Ąïļ **Type your params**: Use TypeScript generics with `useParams` and `useQueryParams` for type safety.
968
+
969
+ 3. ðŸŽŪ **Controllers for guards**: Use controllers for auth checks, data prefetching, and conditional navigation instead of doing it in the component.
970
+
971
+ 4. 🌍 **Global stack for overlays**: Use the global stack for modals/sheets that should appear on top of everything (auth, alerts, etc.).
972
+
973
+ 5. 🏷ïļ **Stack IDs**: Optionally provide custom stack IDs for debugging: `new NavigationStack('my-stack-id')`.
974
+
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
+ ```
979
+
980
+ 7. 🌐 **Web CSS**: Don't forget to import `@sigmela/router/styles.css` in your web entry point.
981
+
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.
238
983
 
239
- Tips
240
- - Prefer path-based navigation throughout your app: it keeps web and native in sync.
241
- - Type your params and query with `useParams<T>()` and `useQueryParams<T>()` to get end-to-end type safety.
242
- - On the web, remember to import `@sigmela/router/styles.css` once.
984
+ ## 📄 License
243
985
 
244
- License
245
986
  MIT
246
987
 
988
+ ---
247
989
 
990
+ Built with âĪïļ by [Sigmela](https://github.com/sigmela)