@sigmela/router 0.3.7 → 0.5.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 (118) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +2 -1
  3. package/README.md +127 -471
  4. package/dist/Router.d.ts +190 -0
  5. package/dist/Router.d.ts.map +1 -0
  6. package/dist/components/Modal.d.ts +56 -0
  7. package/dist/components/Modal.d.ts.map +1 -0
  8. package/dist/components/OverlayContentScope.d.ts +8 -0
  9. package/dist/components/OverlayContentScope.d.ts.map +1 -0
  10. package/dist/components/RouterCloseButton.d.ts +9 -0
  11. package/dist/components/RouterCloseButton.d.ts.map +1 -0
  12. package/dist/components/Sheet.d.ts +56 -0
  13. package/dist/components/Sheet.d.ts.map +1 -0
  14. package/dist/components/SheetSurface.d.ts +27 -0
  15. package/dist/components/SheetSurface.d.ts.map +1 -0
  16. package/dist/components/index.d.ts +4 -0
  17. package/dist/components/index.d.ts.map +1 -0
  18. package/dist/components/pageScrollLock.d.ts +3 -0
  19. package/dist/components/pageScrollLock.d.ts.map +1 -0
  20. package/dist/components/sheetDragEngine.d.ts +27 -0
  21. package/dist/components/sheetDragEngine.d.ts.map +1 -0
  22. package/dist/index.d.ts +5 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +3696 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/navigationHistory.d.ts +17 -0
  27. package/dist/navigationHistory.d.ts.map +1 -0
  28. package/dist/router.css +975 -0
  29. package/dist/types.d.ts +3 -0
  30. package/dist/types.d.ts.map +1 -0
  31. package/dist/utils/id.d.ts +2 -0
  32. package/dist/utils/id.d.ts.map +1 -0
  33. package/package.json +73 -174
  34. package/lib/module/Drawer/Drawer.js +0 -250
  35. package/lib/module/Drawer/DrawerContext.js +0 -4
  36. package/lib/module/Drawer/DrawerIcon.web.js +0 -47
  37. package/lib/module/Drawer/RenderDrawer.native.js +0 -248
  38. package/lib/module/Drawer/RenderDrawer.web.js +0 -197
  39. package/lib/module/Drawer/useDrawer.js +0 -11
  40. package/lib/module/Navigation.js +0 -80
  41. package/lib/module/NavigationStack.js +0 -254
  42. package/lib/module/Router.js +0 -2171
  43. package/lib/module/RouterContext.js +0 -33
  44. package/lib/module/ScreenStack/ScreenStack.native.js +0 -3
  45. package/lib/module/ScreenStack/ScreenStack.web.js +0 -561
  46. package/lib/module/ScreenStack/ScreenStackContext.js +0 -21
  47. package/lib/module/ScreenStack/animationHelpers.js +0 -87
  48. package/lib/module/ScreenStack/index.js +0 -3
  49. package/lib/module/ScreenStackItem/ScreenStackItem.js +0 -70
  50. package/lib/module/ScreenStackItem/ScreenStackItem.types.js +0 -3
  51. package/lib/module/ScreenStackItem/ScreenStackItem.web.js +0 -171
  52. package/lib/module/ScreenStackItem/index.js +0 -3
  53. package/lib/module/ScreenStackSheetItem/ScreenStackSheetItem.native.js +0 -70
  54. package/lib/module/ScreenStackSheetItem/ScreenStackSheetItem.web.js +0 -5
  55. package/lib/module/ScreenStackSheetItem/index.js +0 -3
  56. package/lib/module/SplitView/RenderSplitView.native.js +0 -101
  57. package/lib/module/SplitView/RenderSplitView.web.js +0 -107
  58. package/lib/module/SplitView/SplitView.js +0 -97
  59. package/lib/module/SplitView/SplitViewContext.js +0 -4
  60. package/lib/module/SplitView/index.js +0 -5
  61. package/lib/module/SplitView/useSplitView.js +0 -11
  62. package/lib/module/StackRenderer.js +0 -42
  63. package/lib/module/TabBar/RenderTabBar.native.js +0 -418
  64. package/lib/module/TabBar/RenderTabBar.web.js +0 -218
  65. package/lib/module/TabBar/TabBar.js +0 -208
  66. package/lib/module/TabBar/TabBarContext.js +0 -4
  67. package/lib/module/TabBar/TabIcon.web.js +0 -47
  68. package/lib/module/TabBar/index.js +0 -6
  69. package/lib/module/TabBar/useTabBar.js +0 -11
  70. package/lib/module/TabBar/useTabBarHeight.js +0 -22
  71. package/lib/module/createController.js +0 -5
  72. package/lib/module/index.js +0 -15
  73. package/lib/module/navigationNode.js +0 -3
  74. package/lib/module/package.json +0 -1
  75. package/lib/module/styles.css +0 -1111
  76. package/lib/module/types.js +0 -13
  77. package/lib/typescript/package.json +0 -1
  78. package/lib/typescript/src/Drawer/Drawer.d.ts +0 -100
  79. package/lib/typescript/src/Drawer/DrawerContext.d.ts +0 -3
  80. package/lib/typescript/src/Drawer/DrawerIcon.web.d.ts +0 -7
  81. package/lib/typescript/src/Drawer/RenderDrawer.native.d.ts +0 -8
  82. package/lib/typescript/src/Drawer/RenderDrawer.web.d.ts +0 -8
  83. package/lib/typescript/src/Drawer/useDrawer.d.ts +0 -2
  84. package/lib/typescript/src/Navigation.d.ts +0 -17
  85. package/lib/typescript/src/NavigationStack.d.ts +0 -46
  86. package/lib/typescript/src/Router.d.ts +0 -232
  87. package/lib/typescript/src/RouterContext.d.ts +0 -18
  88. package/lib/typescript/src/ScreenStack/ScreenStack.native.d.ts +0 -2
  89. package/lib/typescript/src/ScreenStack/ScreenStack.web.d.ts +0 -9
  90. package/lib/typescript/src/ScreenStack/ScreenStackContext.d.ts +0 -31
  91. package/lib/typescript/src/ScreenStack/animationHelpers.d.ts +0 -6
  92. package/lib/typescript/src/ScreenStack/index.d.ts +0 -2
  93. package/lib/typescript/src/ScreenStackItem/ScreenStackItem.d.ts +0 -3
  94. package/lib/typescript/src/ScreenStackItem/ScreenStackItem.types.d.ts +0 -14
  95. package/lib/typescript/src/ScreenStackItem/ScreenStackItem.web.d.ts +0 -3
  96. package/lib/typescript/src/ScreenStackItem/index.d.ts +0 -3
  97. package/lib/typescript/src/ScreenStackSheetItem/ScreenStackSheetItem.native.d.ts +0 -11
  98. package/lib/typescript/src/ScreenStackSheetItem/ScreenStackSheetItem.web.d.ts +0 -2
  99. package/lib/typescript/src/ScreenStackSheetItem/index.d.ts +0 -2
  100. package/lib/typescript/src/SplitView/RenderSplitView.native.d.ts +0 -19
  101. package/lib/typescript/src/SplitView/RenderSplitView.web.d.ts +0 -8
  102. package/lib/typescript/src/SplitView/SplitView.d.ts +0 -33
  103. package/lib/typescript/src/SplitView/SplitViewContext.d.ts +0 -3
  104. package/lib/typescript/src/SplitView/index.d.ts +0 -5
  105. package/lib/typescript/src/SplitView/useSplitView.d.ts +0 -2
  106. package/lib/typescript/src/StackRenderer.d.ts +0 -8
  107. package/lib/typescript/src/TabBar/RenderTabBar.native.d.ts +0 -8
  108. package/lib/typescript/src/TabBar/RenderTabBar.web.d.ts +0 -8
  109. package/lib/typescript/src/TabBar/TabBar.d.ts +0 -112
  110. package/lib/typescript/src/TabBar/TabBarContext.d.ts +0 -3
  111. package/lib/typescript/src/TabBar/TabIcon.web.d.ts +0 -7
  112. package/lib/typescript/src/TabBar/index.d.ts +0 -5
  113. package/lib/typescript/src/TabBar/useTabBar.d.ts +0 -2
  114. package/lib/typescript/src/TabBar/useTabBarHeight.d.ts +0 -18
  115. package/lib/typescript/src/createController.d.ts +0 -15
  116. package/lib/typescript/src/index.d.ts +0 -21
  117. package/lib/typescript/src/navigationNode.d.ts +0 -41
  118. package/lib/typescript/src/types.d.ts +0 -140
package/README.md CHANGED
@@ -1,537 +1,193 @@
1
- # Sigmela Router (`@sigmela/router`)
1
+ # @sigmela/router
2
2
 
3
- Modern, lightweight navigation for **React Native** and **Web**, built on top of [`react-native-screens`](https://github.com/software-mansion/react-native-screens).
3
+ A router for React web apps that behave like native apps: screen stacks with
4
+ animated push/pop, modals and bottom sheets with swipe-to-dismiss, tab bars,
5
+ drawers and split views — all declared as JSX routes.
4
6
 
5
- This library is **URL-first**: you navigate by **paths** (`/users/42?tab=posts`), and the router derives `params` and `query` for screens.
6
-
7
- ## Features
8
-
9
- - **Stacks**: predictable stack-based navigation
10
- - **Tabs**: `TabBar` with native + web renderers (or custom tab bar)
11
- - **Drawer**: side-panel navigation (`Drawer`)
12
- - **Split view**: master/details navigation (`SplitView`)
13
- - **Modals & sheets**: via `stackPresentation` (`modal`, `sheet`, …)
14
- - **Controllers**: async/guarded navigation (only present when ready)
15
- - **Appearance**: global styling via `NavigationAppearance` (tab bar colors, fonts, blur effects, etc.)
16
- - **Web History integration**: keeps Router state in sync with `pushState`, `replaceState`, `popstate`
17
- - **Dynamic root**: swap root navigation tree at runtime (`router.setRoot`)
18
- - **Type-safe hooks**: `useParams`, `useQueryParams`, `useRoute`, `useCurrentRoute`
19
-
20
- ## Installation
7
+ Built on [wouter](https://github.com/molefrog/wouter) for matching and
8
+ [motion](https://motion.dev) for transitions.
21
9
 
22
10
  ```bash
23
- yarn add @sigmela/router react-native-screens
24
- # optional (required only if you use sheet presentation)
25
- yarn add @sigmela/native-sheet
11
+ npm install @sigmela/router
26
12
  ```
27
13
 
28
- ### Peer dependencies
29
-
30
- - `react`
31
- - `react-native`
32
- - `react-native-screens` (>= `4.24.0`)
33
- - `@sigmela/native-sheet` (>= `0.0.1`) — only if you use sheets
34
-
35
- ### Web CSS
36
-
37
- On web you must import the bundled stylesheet **once**:
38
-
39
- ```ts
40
- import '@sigmela/router/styles.css';
41
- ```
14
+ `react` and `react-dom` (>= 19) are peer dependencies.
42
15
 
43
16
  ## Quick start
44
17
 
45
- ### Simple stack
46
-
47
- ```tsx
48
- import {
49
- Navigation,
50
- NavigationStack,
51
- Router,
52
- useParams,
53
- useQueryParams,
54
- useRouter,
55
- } from '@sigmela/router';
56
-
57
- function HomeScreen() {
58
- const router = useRouter();
59
- return (
60
- <Button
61
- title="Open details"
62
- onPress={() => router.navigate('/details/42?from=home')}
63
- />
64
- );
65
- }
66
-
67
- function DetailsScreen() {
68
- const { id } = useParams<{ id: string }>();
69
- const { from } = useQueryParams<{ from?: string }>();
70
- return <Text>Details: id={id}, from={from ?? 'n/a'}</Text>;
71
- }
72
-
73
- const rootStack = new NavigationStack()
74
- .addScreen('/', HomeScreen, { header: { title: 'Home' } })
75
- .addScreen('/details/:id', DetailsScreen, { header: { title: 'Details' } });
76
-
77
- const router = new Router({ roots: { app: rootStack }, root: 'app' });
78
-
79
- export default function App() {
80
- return <Navigation router={router} />;
81
- }
82
- ```
83
-
84
- ### Tabs
85
-
86
- ```tsx
87
- import { Navigation, NavigationStack, Router, TabBar } from '@sigmela/router';
88
-
89
- const homeStack = new NavigationStack().addScreen('/', HomeScreen);
90
-
91
- const catalogStack = new NavigationStack()
92
- .addScreen('/catalog', CatalogScreen)
93
- .addScreen('/catalog/products/:productId', ProductScreen);
94
-
95
- const tabBar = new TabBar({ initialIndex: 0 })
96
- .addTab({ key: 'home', stack: homeStack, title: 'Home' })
97
- .addTab({ key: 'catalog', stack: catalogStack, title: 'Catalog' });
98
-
99
- const router = new Router({ roots: { app: tabBar }, root: 'app' });
100
-
101
- export default function App() {
102
- return <Navigation router={router} />;
103
- }
104
- ```
105
-
106
- ### Tabs wrapped by a root stack (like `example/src/navigation/stacks.ts`)
107
-
108
- 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.
109
-
110
- ```tsx
111
- import { NavigationStack, TabBar } from '@sigmela/router';
112
-
113
- const homeStack = new NavigationStack().addScreen('/', HomeScreen);
114
- const catalogStack = new NavigationStack().addScreen('/catalog', CatalogScreen);
115
-
116
- const tabBar = new TabBar()
117
- .addTab({
118
- key: 'home',
119
- stack: homeStack,
120
- title: 'Home',
121
- icon: require('./assets/home.png'),
122
- })
123
- .addTab({
124
- key: 'catalog',
125
- stack: catalogStack,
126
- title: 'Catalog',
127
- icon: require('./assets/catalog.png'),
128
- });
129
-
130
- // Root stack hosts the tab bar AND top-level modals/overlays.
131
- export const rootStack = new NavigationStack()
132
- .addScreen('/', tabBar)
133
- .addModal('/auth', AuthScreen, { header: { title: 'Login', hidden: true } })
134
- .addModal('*?modal=promo', PromoModal);
135
- ```
136
-
137
- ## Core concepts
138
-
139
- ### `NavigationStack`
140
-
141
- A `NavigationStack` defines a set of routes and how to match them.
142
-
143
- ```tsx
144
- const stack = new NavigationStack({ header: { largeTitle: true } })
145
- .addScreen('/feed', FeedScreen)
146
- .addScreen('/feed/:id', FeedItemScreen)
147
- .addModal('/auth', AuthScreen)
148
- .addSheet('/settings', SettingsSheet);
149
- ```
150
-
151
- Key methods:
152
- - `addScreen(pathPattern, componentOrNode, options?)`
153
- - `addModal(pathPattern, componentOrStack, options?)` (shorthand for `stackPresentation: 'modal'`)
154
- - `addSheet(pathPattern, componentOrStack, options?)` (shorthand for `stackPresentation: 'sheet'`)
155
- - `addStack(prefixOrStack, maybeStack?)` — compose nested stacks under a prefix
156
-
157
- #### Provider Context
158
-
159
- You can wrap an entire stack with a React context provider by passing a `provider` option:
160
-
161
- ```tsx
162
- import { ThemeProvider } from './theme';
163
-
164
- const stack = new NavigationStack({
165
- header: { largeTitle: true },
166
- provider: ThemeProvider,
167
- })
168
- .addScreen('/feed', FeedScreen)
169
- .addScreen('/feed/:id', FeedItemScreen);
170
- ```
171
-
172
- The `provider` component wraps the entire stack renderer, making the context available to all screens in the stack. This is useful for:
173
- - **Theme providers**: Apply theme context to all screens
174
- - **Auth providers**: Share authentication state across screens
175
- - **Localization**: Provide i18n context to the entire stack
176
-
177
- **Composing multiple providers:**
178
-
179
- If you need multiple providers, create a composed component:
180
-
181
18
  ```tsx
182
- const ComposedProvider = ({ children }) => (
183
- <ThemeProvider>
184
- <AuthProvider>
185
- <I18nProvider>
186
- {children}
187
- </I18nProvider>
188
- </AuthProvider>
189
- </ThemeProvider>
190
- );
191
-
192
- const stack = new NavigationStack({ provider: ComposedProvider })
193
- .addScreen('/', HomeScreen);
194
- ```
195
-
196
- **Important:** The provider should be a stable reference (not an inline arrow function) to avoid unnecessary re-renders.
197
-
198
- ### Modal Stacks (Stack in Stack)
199
-
200
- You can pass an entire `NavigationStack` to `addModal()` or `addSheet()` to create a multi-screen flow inside a modal:
201
-
202
- ```tsx
203
- // Define a flow with multiple screens
204
- const emailVerifyStack = new NavigationStack()
205
- .addScreen('/verify', EmailInputScreen)
206
- .addScreen('/verify/sent', EmailSentScreen);
207
-
208
- // Mount the entire stack as a modal
209
- const rootStack = new NavigationStack()
210
- .addScreen('/', HomeScreen)
211
- .addModal('/verify', emailVerifyStack);
212
- ```
19
+ import { Router, useRouter } from '@sigmela/router';
213
20
 
214
- **How it works:**
215
- - Navigating to `/verify` opens the modal with `EmailInputScreen`
216
- - Inside the modal, `router.navigate('/verify/sent')` pushes `EmailSentScreen` within the same modal
217
- - `router.goBack()` navigates back inside the modal stack
218
- - `router.dismiss()` closes the entire modal from any depth
219
-
220
- **Example screen with navigation inside modal:**
221
-
222
- ```tsx
223
- function EmailInputScreen() {
224
- const router = useRouter();
225
-
21
+ function App() {
226
22
  return (
227
- <View>
228
- <Button title="Next" onPress={() => router.navigate('/verify/sent')} />
229
- <Button title="Close" onPress={() => router.dismiss()} />
230
- </View>
23
+ <Router>
24
+ <Router.Screen path="/" element={HomeScreen} />
25
+ <Router.Stack path="/products">
26
+ <Router.Screen element={ProductListScreen} />
27
+ <Router.Screen path=":id" element={ProductScreen} />
28
+ <Router.Modal path=":id/edit" element={EditProductModal} />
29
+ <Router.Sheet path=":id/share" element={ShareSheet} />
30
+ </Router.Stack>
31
+ </Router>
231
32
  );
232
33
  }
233
34
 
234
- function EmailSentScreen() {
35
+ function ProductCard({ id }: { id: string }) {
235
36
  const router = useRouter();
236
-
237
- return (
238
- <View>
239
- <Button title="Back" onPress={() => router.goBack()} />
240
- <Button title="Done" onPress={() => router.dismiss()} />
241
- </View>
242
- );
37
+ return <button onClick={() => router.navigate(`/products/${id}`)}>Open</button>;
243
38
  }
244
39
  ```
245
40
 
246
- This pattern works recursively — you can nest stacks inside stacks to any depth.
41
+ Styles ship with the bundle: `dist/index.js` imports `router.css`, so bundlers
42
+ that handle CSS from dependencies (Vite, webpack, Next, Parcel) pick it up
43
+ automatically — no extra import needed.
247
44
 
248
- ### `Router`
249
-
250
- The `Router` holds navigation state and performs path matching.
45
+ Environments without a CSS loader — plain Node, some SSR pipelines — cannot
46
+ resolve that import. Load the stylesheet explicitly there:
251
47
 
252
48
  ```ts
253
- const router = new Router({
254
- roots: { app: root }, // NavigationNode (NavigationStack, TabBar, SplitView, ...)
255
- root: 'app',
256
- screenOptions, // optional defaults
257
- debug, // optional
258
- });
49
+ import '@sigmela/router/router.css';
259
50
  ```
260
51
 
261
- Navigation:
262
- - `router.navigate(path)` — push
263
- - `router.replace(path, dedupe?)` — replace top of the active stack
264
- - `router.goBack()` — pop top of the active stack
265
- - `router.dismiss()` — close the nearest modal or sheet (including all screens in a modal stack)
266
- - `router.reset(path)` — **web-only**: rebuild Router state as if app loaded at `path`
267
- - `router.setRoot(rootKey, { transition? })` — swap root at runtime (`rootKey` from `config.roots`)
268
-
269
- State/subscriptions:
270
- - `router.getState()` → `{ history: HistoryItem[] }`
271
- - `router.getActiveRoute()` → `ActiveRoute | null`
272
- - `router.subscribe(cb)` — notify on any history change
273
- - `router.subscribeStack(stackId, cb)` — notify when a particular stack slice changes
274
- - `router.subscribeRoot(cb)` — notify when root is replaced via `setRoot`
275
- - `router.getStackHistory(stackId)` — slice of history for a stack
52
+ For Vitest with the `node` environment, inline the package so Vite handles the
53
+ CSS import instead of Node:
276
54
 
277
- ### `TabBar`
278
-
279
- `TabBar` is a container node that renders one tab at a time.
280
-
281
- ```tsx
282
- const tabBar = new TabBar({ component: CustomTabBar, initialIndex: 0 })
283
- .addTab({ key: 'home', stack: homeStack, title: 'Home' })
284
- .addTab({ key: 'search', screen: SearchScreen, title: 'Search' });
55
+ ```ts
56
+ // vitest.config.ts
57
+ test: {
58
+ environment: 'node',
59
+ server: { deps: { inline: [/@sigmela\/router/] } },
60
+ }
285
61
  ```
286
62
 
287
- Key methods:
288
- - `addTab({ key, stack?, node?, screen?, prefix?, title?, icon?, selectedIcon?, ... })`
289
- - `onIndexChange(index)` — switch active tab
290
- - `setBadge(index, badge | null)`
291
- - `setTabBarConfig(partialConfig)`
292
- - `getState()` and `subscribe(cb)`
293
-
294
- Notes:
295
- - Exactly one of `stack`, `node`, `screen` must be provided.
296
- - Use `prefix` to mount a tab's routes under a base path (e.g. `/mail`).
297
- - All `TabsScreenProps` from `react-native-screens` are forwarded to native. This includes lifecycle events (`onWillAppear`, `onDidAppear`, `onWillDisappear`, `onDidDisappear`), accessibility props (`testID`, `accessibilityLabel`, `tabBarItemTestID`, `tabBarItemAccessibilityLabel`), `orientation`, `systemItem`, `freezeContents`, `placeholder`, `scrollEdgeEffects`, badge styling, and more.
298
-
299
- #### `setTabBarConfig()`
63
+ ## Route primitives
300
64
 
301
- Runtime tab bar configuration:
65
+ | Element | Purpose |
66
+ | --- | --- |
67
+ | `Router.Screen` | A routed screen inside the current stack |
68
+ | `Router.Stack` | Groups screens that push and pop with a shared animation |
69
+ | `Router.Redirect` | Declarative redirect |
70
+ | `Router.Modal` | Full-cover or content-sized overlay above the current screen |
71
+ | `Router.Sheet` | Bottom sheet, content-sized or fullscreen |
72
+ | `Router.TabBar` / `Router.TabBarItem` | Tab navigation; selecting a tab routes to its root path |
73
+ | `Router.Drawer` / `Router.DrawerGroup` / `Router.DrawerSection` / `Router.DrawerItem` | Persistent side navigation |
74
+ | `Router.SplitView` | Primary/secondary panes above a width breakpoint |
75
+ | `Router.Protected` | Guarded subtree with a redirect target |
302
76
 
303
- ```tsx
304
- tabBar.setTabBarConfig({
305
- bottomAccessory: (environment) => <MiniPlayer layout={environment} />, // iOS 26+
306
- experimentalControlNavigationStateInJS: true,
307
- });
308
- ```
309
-
310
- Web behavior note:
311
- - The built-in **web** tab bar renderer resets Router history on tab switch (to keep URL and Router state consistent) using `router.reset(firstRoutePath)`.
312
-
313
- ### `Drawer`
77
+ ### Overlays with their own stack
314
78
 
315
- `Drawer` provides side-panel navigation, similar to `TabBar` but with a slide-out panel.
79
+ `Router.Modal` and `Router.Sheet` can own a local push stack. The children share
80
+ one surface and leave the browser URL on the underlying screen:
316
81
 
317
82
  ```tsx
318
- import { Drawer, NavigationStack } from '@sigmela/router';
319
-
320
- const homeStack = new NavigationStack().addScreen('/', HomeScreen);
321
- const settingsStack = new NavigationStack().addScreen('/settings', SettingsScreen);
322
-
323
- const drawer = new Drawer({ width: 280 })
324
- .addTab({ key: 'home', stack: homeStack, title: 'Home' })
325
- .addTab({ key: 'settings', stack: settingsStack, title: 'Settings' });
83
+ <Router.Modal path="/auth/email">
84
+ <Router.Screen element={AuthEmailModal} />
85
+ <Router.Screen path="verify" element={AuthEmailVerifyModal} />
86
+ <Router.Screen path="name" element={AuthEmailNameModal} />
87
+ </Router.Modal>
326
88
  ```
327
89
 
328
- Key methods:
329
- - `addTab({ key, stack?, node?, screen?, prefix?, title?, icon?, ... })`
330
- - `open()`, `close()`, `toggle()` — manage drawer state
331
- - `getIsOpen()` — current open state
332
- - `subscribeOpenState(listener)` — subscribe to open/close changes
333
- - `onIndexChange(index)` — switch active tab
334
- - `setBadge(index, badge | null)`
90
+ `navigate()` and `replace()` resolve matching children inside the active
91
+ overlay. `goBack()` pops the local stack first, then closes the overlay from its
92
+ root. The close button, backdrop tap, Escape, swipe dismissal and
93
+ `useCurrentModal().close()` all dismiss the whole flow from any depth.
335
94
 
336
- ### `SplitView`
95
+ ### Content-sized modals
337
96
 
338
- `SplitView` renders **two stacks**: `primary` and `secondary`.
339
-
340
- - On **native**, `secondary` overlays `primary` when it has at least one screen in its history.
341
- - On **web**, the layout becomes side-by-side at a fixed breakpoint (`minWidth`, default `640px`).
97
+ A modal covers the viewport by default. Content that renders `<Modal>` with
98
+ `presentation="content"` sizes the surface to its content instead — floored at
99
+ half the viewport, capped at full height, with the page behind left undimmed:
342
100
 
343
101
  ```tsx
344
- import { NavigationStack, SplitView, TabBar } from '@sigmela/router';
345
-
346
- const master = new NavigationStack().addScreen('/', ThreadsScreen);
347
- const detail = new NavigationStack().addScreen('/:threadId', ThreadScreen);
348
-
349
- const splitView = new SplitView({
350
- minWidth: 640,
351
- primary: master,
352
- secondary: detail,
353
- primaryMaxWidth: 390,
354
- });
355
-
356
- // Mount SplitView directly as a tab (no wrapper stack needed).
357
- const tabBar = new TabBar()
358
- .addTab({ key: 'mail', node: splitView, prefix: '/mail', title: 'Mail' })
359
- .addTab({ key: 'settings', stack: settingsStack, title: 'Settings' });
360
- ```
361
-
362
- ## Controllers
363
-
364
- Controllers let you delay/guard navigation. A route can be registered as:
365
-
366
- ```tsx
367
- import { createController } from '@sigmela/router';
368
-
369
- const UserDetails = {
370
- component: UserDetailsScreen,
371
- controller: createController<{ userId: string }, { tab?: string }>(
372
- async ({ params, query }, present) => {
373
- const ok = await checkAuth();
374
- if (!ok) {
375
- router.replace('/login', true);
376
- return;
377
- }
378
-
379
- const user = await fetchUser(params.userId);
380
- present({ user, tab: query.tab });
381
- }
382
- ),
383
- };
384
-
385
- stack.addScreen('/users/:userId', UserDetails);
386
- ```
387
-
388
- If you never call `present()`, the screen is not pushed/replaced.
389
-
390
- ## Appearance
102
+ import { Modal } from '@sigmela/router';
391
103
 
392
- Pass `NavigationAppearance` to `<Navigation>` to customize styling globally:
393
-
394
- ```tsx
395
- import { Navigation, type NavigationAppearance } from '@sigmela/router';
396
-
397
- const appearance: NavigationAppearance = {
398
- tabBar: {
399
- backgroundColor: '#ffffff',
400
- iconColor: '#999999',
401
- iconColorActive: '#007AFF',
402
- badgeBackgroundColor: '#FF3B30',
403
- iOSShadowColor: '#00000020',
404
- title: {
405
- fontFamily: 'Inter',
406
- fontSize: 10,
407
- color: '#999999',
408
- activeColor: '#007AFF',
409
- activeFontSize: 12, // Android: active tab title font size
410
- },
411
-
412
- // Android-specific
413
- androidActiveIndicatorEnabled: true,
414
- androidActiveIndicatorColor: '#007AFF20',
415
- androidRippleColor: '#007AFF10',
416
- labelVisibilityMode: 'labeled',
417
-
418
- // Tab bar behavior
419
- hidden: false, // hide/show the tab bar
420
- tintColor: '#007AFF', // iOS: selected tab tint + glow color
421
- controllerMode: 'automatic', // iOS 18+: 'automatic' | 'tabBar' | 'tabSidebar'
422
- minimizeBehavior: 'automatic', // iOS 26+: 'automatic' | 'never' | 'onScrollDown' | 'onScrollUp'
423
- nativeContainerBackgroundColor: '#fff', // native container background
424
- iOSBlurEffect: 'systemDefault', // iOS: tab bar blur effect
425
- },
426
- header: { /* ScreenStackHeaderConfigProps */ },
427
- sheet: {
428
- cornerRadius: 16,
429
- backgroundColor: '#ffffff',
430
- },
431
- };
432
-
433
- export default function App() {
434
- return <Navigation router={router} appearance={appearance} />;
104
+ function ProductDetail({ product }) {
105
+ return (
106
+ <Modal presentation={product.image ? 'fullscreen' : 'content'}>
107
+ {/* … */}
108
+ </Modal>
109
+ );
435
110
  }
436
111
  ```
437
112
 
438
113
  ## Hooks
439
114
 
440
- ### `useRouter()`
115
+ | Hook | Returns |
116
+ | --- | --- |
117
+ | `useRouter()` | `navigate`, `replace`, `goBack`, and overlay controls |
118
+ | `useCurrentRoute()` | The active route: `pathname`, `search`, `searchParams` |
119
+ | `useParams()` | Path parameters of the current route |
120
+ | `useSearchParams()` | Query string as `URLSearchParams` |
121
+ | `useRoute(pattern)` | Match test for an arbitrary pattern |
122
+ | `useHistoryState()` | State passed through `navigate(path, { state })` |
123
+ | `useCurrentModal()` | `close()` / `id` for the overlay a component renders in |
124
+ | `useDrawer()` | Drawer open state and controls |
441
125
 
442
- Access the router instance.
126
+ ## Theming
443
127
 
444
- ### `useCurrentRoute()`
128
+ The stylesheet reads CSS custom properties, so an app can restyle every surface
129
+ without overriding selectors. Set them on `:root` or on an app shell element:
445
130
 
446
- Subscribes to `router.getActiveRoute()`.
131
+ **Base** — `--router-bg`, `--router-text`, `--router-line`, `--router-scrim`,
132
+ `--router-container`, `--router-card-shadow`, `--router-glass-bg`,
133
+ `--router-transparent`, `--router-divider`
447
134
 
448
- Returns `ActiveRoute | null` (shape from `src/types.ts`):
135
+ **Overlay/footer** — `--router-footer-fade-bg`,
136
+ `--router-footer-fade-transparent`
449
137
 
450
- ```ts
451
- type ActiveRoute = {
452
- routeId: string;
453
- stackId?: string;
454
- tabIndex?: number;
455
- path?: string;
456
- params?: Record<string, unknown>;
457
- query?: Record<string, unknown>;
458
- } | null;
459
- ```
138
+ **Drawer** — `--router-drawer-bg`, `--router-drawer-text`,
139
+ `--router-drawer-hover-bg`, `--router-drawer-active-bg`,
140
+ `--router-drawer-badge-bg`, `--router-drawer-badge-text`,
141
+ `--router-drawer-focus-ring`, `--router-drawer-overlay-color`,
142
+ `--router-drawer-overlay-bg`, `--router-drawer-overlay-open-bg`
460
143
 
461
- ### `useParams<T>()` / `useQueryParams<T>()`
144
+ **Close button** — `--router-close-button-bg`, `--router-close-button-text`,
145
+ `--router-close-button-light-text`, `--router-close-button-glass-bg`,
146
+ `--router-close-button-shadow`, `--router-close-button-shine`,
147
+ `--router-mask-fill`
462
148
 
463
- Returns params/query for the **current screen** (from route context).
149
+ **Typography/layout** — `--router-title-font-family`,
150
+ `--router-title-font-size`, `--router-title-line-height`,
151
+ `--router-title-font-weight`, `--router-safe-area-bottom`
464
152
 
465
- ### `useRoute()`
153
+ Every token falls back to a sensible default, so the router renders correctly
154
+ with no theming at all.
466
155
 
467
- Returns route-local context for the current screen:
156
+ ## Scope
468
157
 
469
- ```ts
470
- type RouteLocalContextValue = {
471
- presentation: StackPresentationTypes;
472
- params?: Record<string, unknown>;
473
- query?: Record<string, unknown>;
474
- pattern?: string;
475
- path?: string;
476
- };
477
- ```
158
+ The router owns navigation, overlay surfaces and their gestures. It never
159
+ imports application features, i18n, data layers or design-system components —
160
+ screens and layouts are supplied by the host app as JSX.
478
161
 
479
- ### `useTabBar()`
162
+ ## Sandbox
480
163
 
481
- Returns the nearest `TabBar` from context (only inside tab screens).
164
+ `example/` is a Vite playground that exercises every primitive against the
165
+ router source, with hot reload:
482
166
 
483
- ```tsx
484
- import { useTabBar } from '@sigmela/router';
485
-
486
- function ScreenInsideTabs() {
487
- const tabBar = useTabBar();
488
-
489
- return (
490
- <Button
491
- title="Go to second tab"
492
- onPress={() => tabBar.onIndexChange(1)}
493
- />
494
- );
495
- }
167
+ ```bash
168
+ npm install
169
+ npm run example # http://localhost:5180
496
170
  ```
497
171
 
498
- ### `useTabBarHeight()`
172
+ It has three shells you can switch between: **Stack & overlays** (pushes,
173
+ params, search params, fullscreen and content-sized modals, a sheet, and a
174
+ modal owning its own three-step stack), **Tab bar** (per-tab routes, history
175
+ state), and **Drawer & split** (drawer groups and sections, a split view, and a
176
+ guarded route). `example/src/theme.css` shows the `--router-*` tokens driving
177
+ the whole look.
499
178
 
500
- Returns the tab bar height constant (`57`). Useful for bottom padding.
179
+ ## Development
501
180
 
502
- ### `useDrawer()`
503
-
504
- Returns the nearest `Drawer` from context (only inside drawer screens).
505
-
506
- ```tsx
507
- import { useDrawer } from '@sigmela/router';
508
-
509
- function ScreenInsideDrawer() {
510
- const drawer = useDrawer();
511
- return <Button title="Open menu" onPress={() => drawer.open()} />;
512
- }
181
+ ```bash
182
+ npm install
183
+ npm run typecheck
184
+ npm test # 58 contract tests
185
+ npm run lint
186
+ npm run build # dist/index.js + dist/router.css + type declarations
187
+ npm run example # Vite sandbox on the source
513
188
  ```
514
189
 
515
- ### `useSplitView()`
516
-
517
- Returns the nearest `SplitView` from context (only inside split view screens).
518
-
519
- ## Web integration
520
-
521
- ### History API syncing
522
-
523
- On web, Router integrates with the browser History API using custom events:
524
-
525
- - `router.navigate('/x')` writes `history.pushState({ __srPath: ... })`
526
- - `router.replace('/x')` writes `history.replaceState({ __srPath: ... })`
527
- - Browser back/forward triggers `popstate` and Router updates its state accordingly
528
-
529
- Important behavioral detail:
530
- - `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).
531
-
532
- ### `syncWithUrl: false`
533
-
534
- If a route has `screenOptions.syncWithUrl = false`, Router stores the “real” router path in `history.state.__srPath` while keeping the visible URL unchanged.
190
+ `ROUTER_CONTRACT.md` documents the invariants the contract tests protect.
535
191
 
536
192
  ## License
537
193