@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.
- package/CHANGELOG.md +38 -0
- package/LICENSE +2 -1
- package/README.md +127 -471
- package/dist/Router.d.ts +190 -0
- package/dist/Router.d.ts.map +1 -0
- package/dist/components/Modal.d.ts +56 -0
- package/dist/components/Modal.d.ts.map +1 -0
- package/dist/components/OverlayContentScope.d.ts +8 -0
- package/dist/components/OverlayContentScope.d.ts.map +1 -0
- package/dist/components/RouterCloseButton.d.ts +9 -0
- package/dist/components/RouterCloseButton.d.ts.map +1 -0
- package/dist/components/Sheet.d.ts +56 -0
- package/dist/components/Sheet.d.ts.map +1 -0
- package/dist/components/SheetSurface.d.ts +27 -0
- package/dist/components/SheetSurface.d.ts.map +1 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/pageScrollLock.d.ts +3 -0
- package/dist/components/pageScrollLock.d.ts.map +1 -0
- package/dist/components/sheetDragEngine.d.ts +27 -0
- package/dist/components/sheetDragEngine.d.ts.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3696 -0
- package/dist/index.js.map +1 -0
- package/dist/navigationHistory.d.ts +17 -0
- package/dist/navigationHistory.d.ts.map +1 -0
- package/dist/router.css +975 -0
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/utils/id.d.ts +2 -0
- package/dist/utils/id.d.ts.map +1 -0
- package/package.json +73 -174
- package/lib/module/Drawer/Drawer.js +0 -250
- package/lib/module/Drawer/DrawerContext.js +0 -4
- package/lib/module/Drawer/DrawerIcon.web.js +0 -47
- package/lib/module/Drawer/RenderDrawer.native.js +0 -248
- package/lib/module/Drawer/RenderDrawer.web.js +0 -197
- package/lib/module/Drawer/useDrawer.js +0 -11
- package/lib/module/Navigation.js +0 -80
- package/lib/module/NavigationStack.js +0 -254
- package/lib/module/Router.js +0 -2171
- package/lib/module/RouterContext.js +0 -33
- package/lib/module/ScreenStack/ScreenStack.native.js +0 -3
- package/lib/module/ScreenStack/ScreenStack.web.js +0 -561
- package/lib/module/ScreenStack/ScreenStackContext.js +0 -21
- package/lib/module/ScreenStack/animationHelpers.js +0 -87
- package/lib/module/ScreenStack/index.js +0 -3
- package/lib/module/ScreenStackItem/ScreenStackItem.js +0 -70
- package/lib/module/ScreenStackItem/ScreenStackItem.types.js +0 -3
- package/lib/module/ScreenStackItem/ScreenStackItem.web.js +0 -171
- package/lib/module/ScreenStackItem/index.js +0 -3
- package/lib/module/ScreenStackSheetItem/ScreenStackSheetItem.native.js +0 -70
- package/lib/module/ScreenStackSheetItem/ScreenStackSheetItem.web.js +0 -5
- package/lib/module/ScreenStackSheetItem/index.js +0 -3
- package/lib/module/SplitView/RenderSplitView.native.js +0 -101
- package/lib/module/SplitView/RenderSplitView.web.js +0 -107
- package/lib/module/SplitView/SplitView.js +0 -97
- package/lib/module/SplitView/SplitViewContext.js +0 -4
- package/lib/module/SplitView/index.js +0 -5
- package/lib/module/SplitView/useSplitView.js +0 -11
- package/lib/module/StackRenderer.js +0 -42
- package/lib/module/TabBar/RenderTabBar.native.js +0 -418
- package/lib/module/TabBar/RenderTabBar.web.js +0 -218
- package/lib/module/TabBar/TabBar.js +0 -208
- package/lib/module/TabBar/TabBarContext.js +0 -4
- package/lib/module/TabBar/TabIcon.web.js +0 -47
- package/lib/module/TabBar/index.js +0 -6
- package/lib/module/TabBar/useTabBar.js +0 -11
- package/lib/module/TabBar/useTabBarHeight.js +0 -22
- package/lib/module/createController.js +0 -5
- package/lib/module/index.js +0 -15
- package/lib/module/navigationNode.js +0 -3
- package/lib/module/package.json +0 -1
- package/lib/module/styles.css +0 -1111
- package/lib/module/types.js +0 -13
- package/lib/typescript/package.json +0 -1
- package/lib/typescript/src/Drawer/Drawer.d.ts +0 -100
- package/lib/typescript/src/Drawer/DrawerContext.d.ts +0 -3
- package/lib/typescript/src/Drawer/DrawerIcon.web.d.ts +0 -7
- package/lib/typescript/src/Drawer/RenderDrawer.native.d.ts +0 -8
- package/lib/typescript/src/Drawer/RenderDrawer.web.d.ts +0 -8
- package/lib/typescript/src/Drawer/useDrawer.d.ts +0 -2
- package/lib/typescript/src/Navigation.d.ts +0 -17
- package/lib/typescript/src/NavigationStack.d.ts +0 -46
- package/lib/typescript/src/Router.d.ts +0 -232
- package/lib/typescript/src/RouterContext.d.ts +0 -18
- package/lib/typescript/src/ScreenStack/ScreenStack.native.d.ts +0 -2
- package/lib/typescript/src/ScreenStack/ScreenStack.web.d.ts +0 -9
- package/lib/typescript/src/ScreenStack/ScreenStackContext.d.ts +0 -31
- package/lib/typescript/src/ScreenStack/animationHelpers.d.ts +0 -6
- package/lib/typescript/src/ScreenStack/index.d.ts +0 -2
- package/lib/typescript/src/ScreenStackItem/ScreenStackItem.d.ts +0 -3
- package/lib/typescript/src/ScreenStackItem/ScreenStackItem.types.d.ts +0 -14
- package/lib/typescript/src/ScreenStackItem/ScreenStackItem.web.d.ts +0 -3
- package/lib/typescript/src/ScreenStackItem/index.d.ts +0 -3
- package/lib/typescript/src/ScreenStackSheetItem/ScreenStackSheetItem.native.d.ts +0 -11
- package/lib/typescript/src/ScreenStackSheetItem/ScreenStackSheetItem.web.d.ts +0 -2
- package/lib/typescript/src/ScreenStackSheetItem/index.d.ts +0 -2
- package/lib/typescript/src/SplitView/RenderSplitView.native.d.ts +0 -19
- package/lib/typescript/src/SplitView/RenderSplitView.web.d.ts +0 -8
- package/lib/typescript/src/SplitView/SplitView.d.ts +0 -33
- package/lib/typescript/src/SplitView/SplitViewContext.d.ts +0 -3
- package/lib/typescript/src/SplitView/index.d.ts +0 -5
- package/lib/typescript/src/SplitView/useSplitView.d.ts +0 -2
- package/lib/typescript/src/StackRenderer.d.ts +0 -8
- package/lib/typescript/src/TabBar/RenderTabBar.native.d.ts +0 -8
- package/lib/typescript/src/TabBar/RenderTabBar.web.d.ts +0 -8
- package/lib/typescript/src/TabBar/TabBar.d.ts +0 -112
- package/lib/typescript/src/TabBar/TabBarContext.d.ts +0 -3
- package/lib/typescript/src/TabBar/TabIcon.web.d.ts +0 -7
- package/lib/typescript/src/TabBar/index.d.ts +0 -5
- package/lib/typescript/src/TabBar/useTabBar.d.ts +0 -2
- package/lib/typescript/src/TabBar/useTabBarHeight.d.ts +0 -18
- package/lib/typescript/src/createController.d.ts +0 -15
- package/lib/typescript/src/index.d.ts +0 -21
- package/lib/typescript/src/navigationNode.d.ts +0 -41
- package/lib/typescript/src/types.d.ts +0 -140
package/README.md
CHANGED
|
@@ -1,537 +1,193 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @sigmela/router
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
228
|
-
<
|
|
229
|
-
<
|
|
230
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
###
|
|
95
|
+
### Content-sized modals
|
|
337
96
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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 {
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
+
## Theming
|
|
443
127
|
|
|
444
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
+
**Overlay/footer** — `--router-footer-fade-bg`,
|
|
136
|
+
`--router-footer-fade-transparent`
|
|
449
137
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
+
Every token falls back to a sensible default, so the router renders correctly
|
|
154
|
+
with no theming at all.
|
|
466
155
|
|
|
467
|
-
|
|
156
|
+
## Scope
|
|
468
157
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
162
|
+
## Sandbox
|
|
480
163
|
|
|
481
|
-
|
|
164
|
+
`example/` is a Vite playground that exercises every primitive against the
|
|
165
|
+
router source, with hot reload:
|
|
482
166
|
|
|
483
|
-
```
|
|
484
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
+
## Development
|
|
501
180
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
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
|
|