@sigmela/router 0.3.0 → 0.3.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
@@ -8,9 +8,11 @@ This library is **URL-first**: you navigate by **paths** (`/users/42?tab=posts`)
8
8
 
9
9
  - **Stacks**: predictable stack-based navigation
10
10
  - **Tabs**: `TabBar` with native + web renderers (or custom tab bar)
11
+ - **Drawer**: side-panel navigation (`Drawer`)
11
12
  - **Split view**: master/details navigation (`SplitView`)
12
13
  - **Modals & sheets**: via `stackPresentation` (`modal`, `sheet`, …)
13
14
  - **Controllers**: async/guarded navigation (only present when ready)
15
+ - **Appearance**: global styling via `NavigationAppearance` (tab bar colors, fonts, blur effects, etc.)
14
16
  - **Web History integration**: keeps Router state in sync with `pushState`, `replaceState`, `popstate`
15
17
  - **Dynamic root**: swap root navigation tree at runtime (`router.setRoot`)
16
18
  - **Type-safe hooks**: `useParams`, `useQueryParams`, `useRoute`, `useCurrentRoute`
@@ -27,7 +29,7 @@ yarn add @sigmela/native-sheet
27
29
 
28
30
  - `react`
29
31
  - `react-native`
30
- - `react-native-screens` (>= `4.18.0`)
32
+ - `react-native-screens` (>= `4.24.0`)
31
33
  - `@sigmela/native-sheet` (>= `0.0.1`) — only if you use sheets
32
34
 
33
35
  ### Web CSS
@@ -292,10 +294,45 @@ Key methods:
292
294
  Notes:
293
295
  - Exactly one of `stack`, `node`, `screen` must be provided.
294
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()`
300
+
301
+ Runtime tab bar configuration:
302
+
303
+ ```tsx
304
+ tabBar.setTabBarConfig({
305
+ bottomAccessory: (environment) => <MiniPlayer layout={environment} />, // iOS 26+
306
+ experimentalControlNavigationStateInJS: true,
307
+ });
308
+ ```
295
309
 
296
310
  Web behavior note:
297
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)`.
298
312
 
313
+ ### `Drawer`
314
+
315
+ `Drawer` provides side-panel navigation, similar to `TabBar` but with a slide-out panel.
316
+
317
+ ```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' });
326
+ ```
327
+
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)`
335
+
299
336
  ### `SplitView`
300
337
 
301
338
  `SplitView` renders **two stacks**: `primary` and `secondary`.
@@ -350,6 +387,54 @@ stack.addScreen('/users/:userId', UserDetails);
350
387
 
351
388
  If you never call `present()`, the screen is not pushed/replaced.
352
389
 
390
+ ## Appearance
391
+
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} />;
435
+ }
436
+ ```
437
+
353
438
  ## Hooks
354
439
 
355
440
  ### `useRouter()`
@@ -410,6 +495,27 @@ function ScreenInsideTabs() {
410
495
  }
411
496
  ```
412
497
 
498
+ ### `useTabBarHeight()`
499
+
500
+ Returns the tab bar height constant (`57`). Useful for bottom padding.
501
+
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
+ }
513
+ ```
514
+
515
+ ### `useSplitView()`
516
+
517
+ Returns the nearest `SplitView` from context (only inside split view screens).
518
+
413
519
  ## Web integration
414
520
 
415
521
  ### History API syncing
@@ -5,6 +5,7 @@ import { DrawerContext } from "./DrawerContext.js";
5
5
  import { useRouter } from "../RouterContext.js";
6
6
  import { ScreenStackItem } from 'react-native-screens';
7
7
  import { Pressable, StyleSheet, View, Text } from 'react-native';
8
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
8
9
  import { useCallback, useSyncExternalStore, memo, useEffect, useState, useMemo } from 'react';
9
10
  import Animated, { useSharedValue, useAnimatedStyle, withTiming, Easing } from 'react-native-reanimated';
10
11
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
@@ -43,6 +44,7 @@ export const RenderDrawer = /*#__PURE__*/memo(({
43
44
  appearance = {}
44
45
  }) => {
45
46
  const router = useRouter();
47
+ const insets = useSafeAreaInsets();
46
48
  const drawerWidth = drawer.width;
47
49
  const subscribe = useCallback(cb => drawer.subscribe(cb), [drawer]);
48
50
  const snapshot = useSyncExternalStore(subscribe, drawer.getState, drawer.getState);
@@ -143,7 +145,9 @@ export const RenderDrawer = /*#__PURE__*/memo(({
143
145
  isOpen: isOpen,
144
146
  onClose: handleOverlayPress
145
147
  }) : /*#__PURE__*/_jsx(View, {
146
- style: styles.sidebarContent,
148
+ style: [styles.sidebarContent, {
149
+ paddingTop: insets.top + 12
150
+ }],
147
151
  children: tabs.map((tab, i) => {
148
152
  const isActive = i === index;
149
153
  return /*#__PURE__*/_jsx(Pressable, {
@@ -200,8 +204,7 @@ const styles = StyleSheet.create({
200
204
  backgroundColor: '#ffffff'
201
205
  },
202
206
  sidebarContent: {
203
- flex: 1,
204
- paddingTop: 12
207
+ flex: 1
205
208
  },
206
209
  main: {
207
210
  flex: 1,
@@ -4,33 +4,67 @@ import { ScreenStackItem } from "../ScreenStackItem/index.js";
4
4
  import { ScreenStack } from "../ScreenStack/index.js";
5
5
  import { SplitViewContext } from "./SplitViewContext.js";
6
6
  import { useRouter } from "../RouterContext.js";
7
- import { memo, useCallback, useSyncExternalStore, useMemo } from 'react';
7
+ import { memo, useMemo, useState, useEffect, startTransition } from 'react';
8
8
  import { StyleSheet } from 'react-native';
9
9
  import { jsx as _jsx } from "react/jsx-runtime";
10
10
  /**
11
- * On native (iPhone), SplitView renders primary and secondary screens
12
- * in a SINGLE ScreenStack to get native push/pop animations.
11
+ * On native, SplitView renders primary and secondary screens in a SINGLE
12
+ * ScreenStack to get native push/pop animations.
13
13
  *
14
14
  * The combined history is: [...primaryHistory, ...secondaryHistory]
15
15
  * This way, navigating from primary to secondary is a native push.
16
+ *
17
+ * On Android (Fabric) react-native-screens manages each ScreenStackItem via
18
+ * an Android Fragment. Fragment lifecycle (onCreateView) is asynchronous,
19
+ * but Fabric's layout pass is synchronous. If a new commit is dispatched
20
+ * while the previous Fragment transaction is still in flight, Fabric tries
21
+ * to insert child views into a Screen whose Fragment isn't attached yet:
22
+ *
23
+ * "addViewAt: Parent Screen does not have its Fragment attached"
24
+ *
25
+ * The fix follows the same pattern used in react-native-screens' own
26
+ * BottomTabsContainer example: subscribe via useEffect and apply updates
27
+ * inside `startTransition`. This marks the update as non-urgent, letting
28
+ * React defer the Fabric commit until pending Fragment transactions complete.
29
+ *
30
+ * See: https://github.com/software-mansion/react-native-screens/blob/ddd1a9e/
31
+ * apps/src/shared/gamma/containers/bottom-tabs/BottomTabsContainer.tsx
16
32
  */
17
33
  export const RenderSplitView = /*#__PURE__*/memo(({
18
34
  splitView,
19
35
  appearance
20
36
  }) => {
21
37
  const router = useRouter();
22
-
23
- // Subscribe to primary stack
24
38
  const primaryId = splitView.primary.getId();
25
- const subscribePrimary = useCallback(cb => router.subscribeStack(primaryId, cb), [router, primaryId]);
26
- const getPrimary = useCallback(() => router.getStackHistory(primaryId), [router, primaryId]);
27
- const primaryHistory = useSyncExternalStore(subscribePrimary, getPrimary, getPrimary);
28
-
29
- // Subscribe to secondary stack
30
39
  const secondaryId = splitView.secondary.getId();
31
- const subscribeSecondary = useCallback(cb => router.subscribeStack(secondaryId, cb), [router, secondaryId]);
32
- const getSecondary = useCallback(() => router.getStackHistory(secondaryId), [router, secondaryId]);
33
- const secondaryHistory = useSyncExternalStore(subscribeSecondary, getSecondary, getSecondary);
40
+
41
+ // Subscribe to both stacks via useEffect + startTransition.
42
+ // startTransition defers the Fabric commit so the FragmentManager has
43
+ // time to finish pending transactions between commits.
44
+ const [primaryHistory, setPrimaryHistory] = useState(() => router.getStackHistory(primaryId));
45
+ const [secondaryHistory, setSecondaryHistory] = useState(() => router.getStackHistory(secondaryId));
46
+ useEffect(() => {
47
+ const updatePrimary = () => {
48
+ startTransition(() => {
49
+ setPrimaryHistory(router.getStackHistory(primaryId));
50
+ });
51
+ };
52
+ const updateSecondary = () => {
53
+ startTransition(() => {
54
+ setSecondaryHistory(router.getStackHistory(secondaryId));
55
+ });
56
+ };
57
+ const unsub1 = router.subscribeStack(primaryId, updatePrimary);
58
+ const unsub2 = router.subscribeStack(secondaryId, updateSecondary);
59
+
60
+ // Sync in case the store changed between useState init and useEffect
61
+ updatePrimary();
62
+ updateSecondary();
63
+ return () => {
64
+ unsub1();
65
+ unsub2();
66
+ };
67
+ }, [router, primaryId, secondaryId]);
34
68
 
35
69
  // Fallback: if primary is empty, seed with first route
36
70
  const primaryHistoryToRender = useMemo(() => {
@@ -56,9 +90,6 @@ export const RenderSplitView = /*#__PURE__*/memo(({
56
90
  const combinedHistory = useMemo(() => {
57
91
  return [...primaryHistoryToRender, ...secondaryHistory];
58
92
  }, [primaryHistoryToRender, secondaryHistory]);
59
-
60
- // Use primary stack ID for the combined ScreenStack
61
- // (secondary items will animate as if pushed onto this stack)
62
93
  return /*#__PURE__*/_jsx(SplitViewContext.Provider, {
63
94
  value: splitView,
64
95
  children: /*#__PURE__*/_jsx(ScreenStack, {
@@ -3,7 +3,7 @@
3
3
  import { StackRenderer } from "../StackRenderer.js";
4
4
  import { TabBarContext } from "./TabBarContext.js";
5
5
  import { useRouter } from "../RouterContext.js";
6
- import { BottomTabsScreen, BottomTabs, ScreenStackItem } from 'react-native-screens';
6
+ import { Tabs } from 'react-native-screens';
7
7
  import { Platform, StyleSheet, View } from 'react-native';
8
8
  import { useCallback, useSyncExternalStore, memo, useEffect, useState, useMemo } from 'react';
9
9
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
@@ -178,91 +178,71 @@ export const RenderTabBar = /*#__PURE__*/memo(({
178
178
  title,
179
179
  backgroundColor,
180
180
  badgeBackgroundColor,
181
- iOSShadowColor
181
+ iOSShadowColor,
182
+ hidden,
183
+ tintColor,
184
+ controllerMode,
185
+ minimizeBehavior,
186
+ nativeContainerBackgroundColor,
187
+ iOSBlurEffect
182
188
  } = appearance?.tabBar ?? {};
183
189
  const onNativeFocusChange = useCallback(event => {
184
190
  const tabKey = event.nativeEvent.tabKey;
185
191
  const tabIndex = tabs.findIndex(route => route.tabKey === tabKey);
186
192
  if (tabIndex === -1) return;
187
- const targetTab = tabs[tabIndex];
188
- if (!targetTab) return;
189
- const targetStack = tabBar.stacks[targetTab.tabKey];
190
- const targetNode = tabBar.nodes[targetTab.tabKey];
191
193
 
192
- // Update TabBar UI state
194
+ // Keep native Tabs.Host focus state as the source of truth.
193
195
  if (tabIndex !== index) {
194
196
  tabBar.onIndexChange(tabIndex);
195
197
  }
196
-
197
- // Navigate to the target stack's first route if needed
198
+ }, [tabs, tabBar, index]);
199
+ const seedTabIfNeeded = useCallback(targetTab => {
200
+ if (!targetTab) return;
201
+ const targetStack = tabBar.stacks[targetTab.tabKey];
202
+ const targetNode = tabBar.nodes[targetTab.tabKey];
198
203
  if (targetStack) {
199
204
  const stackId = targetStack.getId();
200
205
  const stackHistory = router.getStackHistory(stackId);
201
- // Only navigate if stack is empty (first visit)
202
206
  if (stackHistory.length === 0) {
203
207
  const firstRoute = targetStack.getFirstRoute();
204
208
  if (firstRoute?.path) {
205
209
  router.navigate(firstRoute.path);
206
210
  }
207
211
  }
208
- } else if (targetNode) {
209
- // For nodes like SplitView, check if we need to seed it
212
+ return;
213
+ }
214
+ if (targetNode) {
210
215
  const nodeId = targetNode.getId?.();
211
- if (nodeId) {
212
- const nodeHistory = router.getStackHistory(nodeId);
213
- if (nodeHistory.length === 0) {
214
- const seed = targetNode.seed?.();
215
- if (seed?.path) {
216
- const prefix = targetTab.tabPrefix ?? '';
217
- const fullPath = prefix && !seed.path.startsWith(prefix) ? `${prefix}${seed.path.startsWith('/') ? '' : '/'}${seed.path}` : seed.path;
218
- router.navigate(fullPath);
219
- }
216
+ if (!nodeId) return;
217
+ const nodeHistory = router.getStackHistory(nodeId);
218
+ if (nodeHistory.length === 0) {
219
+ const seed = targetNode.seed?.();
220
+ if (seed?.path) {
221
+ const prefix = targetTab.tabPrefix ?? '';
222
+ const fullPath = prefix && !seed.path.startsWith(prefix) ? `${prefix}${seed.path.startsWith('/') ? '' : '/'}${seed.path}` : seed.path;
223
+ router.navigate(fullPath);
220
224
  }
221
225
  }
222
226
  }
223
- }, [tabs, tabBar, index, router]);
227
+ }, [tabBar.nodes, tabBar.stacks, router]);
228
+ useEffect(() => {
229
+ seedTabIfNeeded(tabs[index]);
230
+ }, [tabs, index, seedTabIfNeeded]);
224
231
  const onTabPress = useCallback(nextIndex => {
225
232
  const targetTab = tabs[nextIndex];
226
233
  if (!targetTab) return;
227
- const targetStack = tabBar.stacks[targetTab.tabKey];
228
- const targetNode = tabBar.nodes[targetTab.tabKey];
229
234
 
230
235
  // Update TabBar UI state
231
236
  if (nextIndex !== index) {
232
237
  tabBar.onIndexChange(nextIndex);
233
238
  }
234
-
235
- // Navigate to the target stack's first route if needed
236
- if (targetStack) {
237
- const stackId = targetStack.getId();
238
- const stackHistory = router.getStackHistory(stackId);
239
- // Only navigate if stack is empty (first visit)
240
- if (stackHistory.length === 0) {
241
- const firstRoute = targetStack.getFirstRoute();
242
- if (firstRoute?.path) {
243
- router.navigate(firstRoute.path);
244
- }
245
- }
246
- } else if (targetNode) {
247
- // For nodes like SplitView, check if we need to seed it
248
- const nodeId = targetNode.getId?.();
249
- if (nodeId) {
250
- const nodeHistory = router.getStackHistory(nodeId);
251
- if (nodeHistory.length === 0) {
252
- const seed = targetNode.seed?.();
253
- if (seed?.path) {
254
- const prefix = targetTab.tabPrefix ?? '';
255
- const fullPath = prefix && !seed.path.startsWith(prefix) ? `${prefix}${seed.path.startsWith('/') ? '' : '/'}${seed.path}` : seed.path;
256
- router.navigate(fullPath);
257
- }
258
- }
259
- }
260
- }
261
- }, [tabs, tabBar, index, router]);
239
+ seedTabIfNeeded(targetTab);
240
+ }, [tabs, tabBar, index, seedTabIfNeeded]);
262
241
  const containerProps = useMemo(() => ({
263
242
  tabBarBackgroundColor: backgroundColor,
264
243
  tabBarItemTitleFontFamily: title?.fontFamily,
265
244
  tabBarItemTitleFontSize: title?.fontSize,
245
+ tabBarItemTitleFontSizeActive: title?.activeFontSize,
266
246
  tabBarItemTitleFontWeight: title?.fontWeight,
267
247
  tabBarItemTitleFontStyle: title?.fontStyle,
268
248
  tabBarItemTitleFontColor: title?.color,
@@ -272,35 +252,53 @@ export const RenderTabBar = /*#__PURE__*/memo(({
272
252
  tabBarItemActiveIndicatorColor: androidActiveIndicatorColor,
273
253
  tabBarItemActiveIndicatorEnabled: androidActiveIndicatorEnabled,
274
254
  tabBarItemRippleColor: androidRippleColor,
275
- tabBarItemLabelVisibilityMode: labelVisibilityMode
276
- }), [backgroundColor, title?.fontFamily, title?.fontSize, title?.fontWeight, title?.fontStyle, title?.color, title?.activeColor, iconColor, iconColorActive, androidActiveIndicatorColor, androidActiveIndicatorEnabled, androidRippleColor, labelVisibilityMode]);
277
- const iosState = useMemo(() => ({
255
+ tabBarItemLabelVisibilityMode: labelVisibilityMode,
256
+ tabBarHidden: hidden,
257
+ tabBarTintColor: tintColor,
258
+ tabBarControllerMode: controllerMode,
259
+ tabBarMinimizeBehavior: minimizeBehavior,
260
+ nativeContainerStyle: nativeContainerBackgroundColor ? {
261
+ backgroundColor: nativeContainerBackgroundColor
262
+ } : undefined
263
+ }), [backgroundColor, title?.fontFamily, title?.fontSize, title?.activeFontSize, title?.fontWeight, title?.fontStyle, title?.color, title?.activeColor, iconColor, iconColorActive, androidActiveIndicatorColor, androidActiveIndicatorEnabled, androidRippleColor, labelVisibilityMode, hidden, tintColor, controllerMode, minimizeBehavior, nativeContainerBackgroundColor]);
264
+ const iosNormalState = useMemo(() => ({
278
265
  tabBarItemTitleFontFamily: title?.fontFamily,
279
266
  tabBarItemTitleFontSize: title?.fontSize,
280
267
  tabBarItemTitleFontWeight: title?.fontWeight,
281
268
  tabBarItemTitleFontStyle: title?.fontStyle,
282
269
  tabBarItemTitleFontColor: title?.color,
283
270
  tabBarItemBadgeBackgroundColor: badgeBackgroundColor,
284
- tabBarItemTitleFontColorActive: title?.activeColor ?? title?.color,
285
- tabBarItemIconColorActive: iconColorActive,
286
271
  tabBarItemIconColor: iconColor
272
+ }), [title?.fontFamily, title?.fontSize, title?.fontWeight, title?.fontStyle, title?.color, badgeBackgroundColor, iconColor]);
273
+ const iosSelectedState = useMemo(() => ({
274
+ tabBarItemTitleFontFamily: title?.fontFamily,
275
+ tabBarItemTitleFontSize: title?.fontSize,
276
+ tabBarItemTitleFontWeight: title?.fontWeight,
277
+ tabBarItemTitleFontStyle: title?.fontStyle,
278
+ tabBarItemTitleFontColor: title?.activeColor ?? title?.color,
279
+ tabBarItemBadgeBackgroundColor: badgeBackgroundColor,
280
+ tabBarItemIconColor: iconColorActive ?? iconColor
287
281
  }), [title?.fontFamily, title?.fontSize, title?.fontWeight, title?.fontStyle, title?.color, title?.activeColor, badgeBackgroundColor, iconColorActive, iconColor]);
288
282
  const iosAppearance = useMemo(() => Platform.select({
289
283
  default: undefined,
290
284
  ios: {
291
285
  tabBarBackgroundColor: backgroundColor,
292
286
  tabBarShadowColor: iOSShadowColor,
287
+ tabBarBlurEffect: iOSBlurEffect,
293
288
  compactInline: {
294
- normal: iosState
289
+ normal: iosNormalState,
290
+ selected: iosSelectedState
295
291
  },
296
292
  stacked: {
297
- normal: iosState
293
+ normal: iosNormalState,
294
+ selected: iosSelectedState
298
295
  },
299
296
  inline: {
300
- normal: iosState
297
+ normal: iosNormalState,
298
+ selected: iosSelectedState
301
299
  }
302
300
  }
303
- }), [backgroundColor, iOSShadowColor, iosState]);
301
+ }), [backgroundColor, iOSShadowColor, iOSBlurEffect, iosNormalState, iosSelectedState]);
304
302
  const CustomTabBar = config.component;
305
303
  const tabIcons = useMemo(() => tabs.map(tab => getTabIcon(tab)), [tabs]);
306
304
  const [visited, setVisited] = useState({});
@@ -314,78 +312,67 @@ export const RenderTabBar = /*#__PURE__*/memo(({
314
312
  }
315
313
  }, [tabs, index]);
316
314
  if (CustomTabBar) {
317
- return /*#__PURE__*/_jsx(ScreenStackItem, {
318
- screenId: "root-tabbar",
319
- headerConfig: {
320
- hidden: true
321
- },
322
- style: StyleSheet.absoluteFill,
323
- stackAnimation: "slide_from_right",
324
- children: /*#__PURE__*/_jsxs(TabBarContext.Provider, {
325
- value: tabBar,
326
- children: [/*#__PURE__*/_jsx(View, {
327
- style: styles.flex,
328
- children: tabs.filter(t => visited[t.tabKey]).map(tab => {
329
- const isActive = tab.tabKey === tabs[index]?.tabKey;
330
- const stackForTab = tabBar.stacks[tab.tabKey];
331
- const nodeForTab = tabBar.nodes[tab.tabKey];
332
- const ScreenForTab = tabBar.screens[tab.tabKey];
333
- return /*#__PURE__*/_jsx(View, {
334
- style: [styles.flex, !isActive && styles.hidden],
335
- children: stackForTab ? /*#__PURE__*/_jsx(TabStackRenderer, {
336
- appearance: appearance,
337
- stack: stackForTab
338
- }) : nodeForTab ? /*#__PURE__*/_jsx(TabNodeRenderer, {
339
- appearance: appearance,
340
- node: nodeForTab
341
- }) : ScreenForTab ? /*#__PURE__*/_jsx(ScreenForTab, {}) : null
342
- }, `tab-content-${tab.tabKey}`);
343
- })
344
- }), /*#__PURE__*/_jsx(CustomTabBar, {
345
- onTabPress: onTabPress,
346
- activeIndex: index,
347
- tabs: tabs
348
- })]
349
- })
350
- });
351
- }
352
- return /*#__PURE__*/_jsx(ScreenStackItem, {
353
- screenId: "root-tabbar",
354
- headerConfig: {
355
- hidden: true
356
- },
357
- style: StyleSheet.absoluteFill,
358
- stackAnimation: "slide_from_right",
359
- children: /*#__PURE__*/_jsx(TabBarContext.Provider, {
315
+ return /*#__PURE__*/_jsxs(TabBarContext.Provider, {
360
316
  value: tabBar,
361
- children: /*#__PURE__*/_jsx(BottomTabs, {
362
- onNativeFocusChange: onNativeFocusChange,
363
- ...containerProps,
364
- children: tabs.map((tab, i) => {
365
- const isFocused = tab.tabKey === tabs[index]?.tabKey;
366
- const stack = tabBar.stacks[tab.tabKey];
367
- const node = tabBar.nodes[tab.tabKey];
368
- const Screen = tabBar.screens[tab.tabKey];
369
- const icon = tabIcons[i];
370
- return /*#__PURE__*/_jsx(BottomTabsScreen, {
371
- scrollEdgeAppearance: iosAppearance,
372
- standardAppearance: iosAppearance,
373
- isFocused: isFocused,
374
- tabKey: tab.tabKey,
375
- title: tab.title,
376
- badgeValue: tab.badgeValue,
377
- specialEffects: tab.specialEffects,
378
- icon: icon?.icon,
379
- selectedIcon: icon?.selectedIcon,
380
- children: stack ? /*#__PURE__*/_jsx(TabStackRenderer, {
317
+ children: [/*#__PURE__*/_jsx(View, {
318
+ style: styles.flex,
319
+ children: tabs.filter(t => visited[t.tabKey]).map(tab => {
320
+ const isActive = tab.tabKey === tabs[index]?.tabKey;
321
+ const stackForTab = tabBar.stacks[tab.tabKey];
322
+ const nodeForTab = tabBar.nodes[tab.tabKey];
323
+ const ScreenForTab = tabBar.screens[tab.tabKey];
324
+ return /*#__PURE__*/_jsx(View, {
325
+ style: [styles.flex, !isActive && styles.hidden],
326
+ children: stackForTab ? /*#__PURE__*/_jsx(TabStackRenderer, {
381
327
  appearance: appearance,
382
- stack: stack
383
- }) : node ? /*#__PURE__*/_jsx(TabNodeRenderer, {
328
+ stack: stackForTab
329
+ }) : nodeForTab ? /*#__PURE__*/_jsx(TabNodeRenderer, {
384
330
  appearance: appearance,
385
- node: node
386
- }) : Screen ? /*#__PURE__*/_jsx(Screen, {}) : null
387
- }, tab.tabKey);
331
+ node: nodeForTab
332
+ }) : ScreenForTab ? /*#__PURE__*/_jsx(ScreenForTab, {}) : null
333
+ }, `tab-content-${tab.tabKey}`);
388
334
  })
335
+ }), /*#__PURE__*/_jsx(CustomTabBar, {
336
+ onTabPress: onTabPress,
337
+ activeIndex: index,
338
+ tabs: tabs
339
+ })]
340
+ });
341
+ }
342
+ return /*#__PURE__*/_jsx(TabBarContext.Provider, {
343
+ value: tabBar,
344
+ children: /*#__PURE__*/_jsx(Tabs.Host, {
345
+ onNativeFocusChange: onNativeFocusChange,
346
+ bottomAccessory: config.bottomAccessory,
347
+ experimentalControlNavigationStateInJS: config.experimentalControlNavigationStateInJS,
348
+ ...containerProps,
349
+ children: tabs.map((tab, i) => {
350
+ const isFocused = tab.tabKey === tabs[index]?.tabKey;
351
+ const stack = tabBar.stacks[tab.tabKey];
352
+ const node = tabBar.nodes[tab.tabKey];
353
+ const Screen = tabBar.screens[tab.tabKey];
354
+ const convertedIcon = tabIcons[i];
355
+ const {
356
+ icon: _icon,
357
+ selectedIcon: _selectedIcon,
358
+ tabPrefix: _prefix,
359
+ ...tabScreenProps
360
+ } = tab;
361
+ return /*#__PURE__*/_jsx(Tabs.Screen, {
362
+ ...tabScreenProps,
363
+ scrollEdgeAppearance: iosAppearance,
364
+ standardAppearance: iosAppearance,
365
+ isFocused: isFocused,
366
+ icon: convertedIcon?.icon,
367
+ selectedIcon: convertedIcon?.selectedIcon,
368
+ children: stack ? /*#__PURE__*/_jsx(TabStackRenderer, {
369
+ appearance: appearance,
370
+ stack: stack
371
+ }) : node ? /*#__PURE__*/_jsx(TabNodeRenderer, {
372
+ appearance: appearance,
373
+ node: node
374
+ }) : Screen ? /*#__PURE__*/_jsx(Screen, {}) : null
375
+ }, tab.tabKey);
389
376
  })
390
377
  })
391
378
  });
@@ -1094,9 +1094,18 @@
1094
1094
  }
1095
1095
 
1096
1096
  /* ==================== DESKTOP DRAWER (>= 641px) ==================== */
1097
+ /* Inverted logic: sidebar is visible by default, data-drawer-open='true' hides it.
1098
+ This way toggle() works on both viewports with a single boolean:
1099
+ isOpen=false (default) → mobile: hidden, desktop: visible
1100
+ isOpen=true (toggled) → mobile: visible, desktop: hidden */
1097
1101
  @media (min-width: 641px) {
1098
- /* On desktop, drawer is always visible — data-drawer-open is irrelevant */
1099
1102
  .drawer-sidebar {
1100
1103
  border-right: 1px solid rgba(0, 0, 0, 0.08);
1104
+ transition: margin-left var(--drawer-transition);
1105
+ }
1106
+
1107
+ .drawer-container[data-drawer-open='true'] .drawer-sidebar {
1108
+ margin-left: calc(-1 * var(--drawer-width));
1109
+ border-right: none;
1101
1110
  }
1102
1111
  }
@@ -5,11 +5,27 @@ export interface RenderSplitViewProps {
5
5
  appearance?: NavigationAppearance;
6
6
  }
7
7
  /**
8
- * On native (iPhone), SplitView renders primary and secondary screens
9
- * in a SINGLE ScreenStack to get native push/pop animations.
8
+ * On native, SplitView renders primary and secondary screens in a SINGLE
9
+ * ScreenStack to get native push/pop animations.
10
10
  *
11
11
  * The combined history is: [...primaryHistory, ...secondaryHistory]
12
12
  * This way, navigating from primary to secondary is a native push.
13
+ *
14
+ * On Android (Fabric) react-native-screens manages each ScreenStackItem via
15
+ * an Android Fragment. Fragment lifecycle (onCreateView) is asynchronous,
16
+ * but Fabric's layout pass is synchronous. If a new commit is dispatched
17
+ * while the previous Fragment transaction is still in flight, Fabric tries
18
+ * to insert child views into a Screen whose Fragment isn't attached yet:
19
+ *
20
+ * "addViewAt: Parent Screen does not have its Fragment attached"
21
+ *
22
+ * The fix follows the same pattern used in react-native-screens' own
23
+ * BottomTabsContainer example: subscribe via useEffect and apply updates
24
+ * inside `startTransition`. This marks the update as non-urgent, letting
25
+ * React defer the Fabric commit until pending Fragment transactions complete.
26
+ *
27
+ * See: https://github.com/software-mansion/react-native-screens/blob/ddd1a9e/
28
+ * apps/src/shared/gamma/containers/bottom-tabs/BottomTabsContainer.tsx
13
29
  */
14
30
  export declare const RenderSplitView: import("react").NamedExoticComponent<RenderSplitViewProps>;
15
31
  //# sourceMappingURL=RenderSplitView.native.d.ts.map
@@ -4,7 +4,7 @@ import type { ComponentType } from 'react';
4
4
  import type { TabItem } from '../types';
5
5
  import React from 'react';
6
6
  import type { NavigationNode, NodeChild, NodeRoute } from '../navigationNode';
7
- import type { PlatformIcon } from 'react-native-screens';
7
+ import type { PlatformIcon, TabAccessoryComponentFactory } from 'react-native-screens';
8
8
  type LegacyIOSIconShape = {
9
9
  sfSymbolName: string;
10
10
  } | {
@@ -52,6 +52,14 @@ type TabBarConfig = Omit<InternalTabItem, 'tabKey' | 'key'> & {
52
52
  * Custom tab bar component (UI). Kept for compatibility.
53
53
  */
54
54
  component?: ComponentType<TabBarProps>;
55
+ /**
56
+ * iOS 26+ bottom accessory factory.
57
+ */
58
+ bottomAccessory?: TabAccessoryComponentFactory;
59
+ /**
60
+ * Experimental: control navigation state in JS.
61
+ */
62
+ experimentalControlNavigationStateInJS?: boolean;
55
63
  };
56
64
  type TabBarOptions = {
57
65
  component?: ComponentType<TabBarProps>;
@@ -1,5 +1,5 @@
1
1
  import type { ColorValue, StyleProp, ViewStyle, TextStyle } from 'react-native';
2
- import type { BottomTabsScreenProps, ScreenProps as RNSScreenProps, ScreenStackHeaderConfigProps, TabBarItemLabelVisibilityMode, TabBarMinimizeBehavior } from 'react-native-screens';
2
+ import type { TabsScreenProps, ScreenProps as RNSScreenProps, ScreenStackHeaderConfigProps, TabBarItemLabelVisibilityMode, TabBarMinimizeBehavior, TabBarControllerMode, TabAccessoryComponentFactory, TabsScreenBlurEffect } from 'react-native-screens';
3
3
  export type StackPresentationTypes = 'push' | 'modal' | 'modalRight' | 'transparentModal' | 'containedModal' | 'containedTransparentModal' | 'fullScreenModal' | 'formSheet' | 'pageSheet' | 'sheet';
4
4
  /**
5
5
  * Presentations that behave like modals (overlay on top of content).
@@ -9,7 +9,7 @@ export declare const MODAL_LIKE_PRESENTATIONS: ReadonlySet<StackPresentationType
9
9
  * Check if a presentation type is modal-like (renders as overlay).
10
10
  */
11
11
  export declare function isModalLikePresentation(presentation: StackPresentationTypes | undefined): boolean;
12
- export type TabItem = Omit<BottomTabsScreenProps, 'isFocused' | 'children'>;
12
+ export type TabItem = Omit<TabsScreenProps, 'isFocused' | 'children'>;
13
13
  export type NavigationState<Route extends TabItem> = {
14
14
  index: number;
15
15
  routes: Route[];
@@ -98,6 +98,8 @@ export type CompiledRoute = {
98
98
  };
99
99
  export type TabBarConfig = {
100
100
  tabBarMinimizeBehavior?: TabBarMinimizeBehavior;
101
+ bottomAccessory?: TabAccessoryComponentFactory;
102
+ experimentalControlNavigationStateInJS?: boolean;
101
103
  };
102
104
  export type SheetAppearance = {
103
105
  androidFullScreenTopInset?: number;
@@ -115,6 +117,12 @@ export interface NavigationAppearance {
115
117
  androidRippleColor?: ColorValue;
116
118
  labelVisibilityMode?: TabBarItemLabelVisibilityMode;
117
119
  iOSShadowColor?: ColorValue;
120
+ hidden?: boolean;
121
+ tintColor?: ColorValue;
122
+ controllerMode?: TabBarControllerMode;
123
+ minimizeBehavior?: TabBarMinimizeBehavior;
124
+ nativeContainerBackgroundColor?: ColorValue;
125
+ iOSBlurEffect?: TabsScreenBlurEffect;
118
126
  title: {
119
127
  fontFamily?: TextStyle['fontFamily'];
120
128
  fontSize?: TextStyle['fontSize'];
@@ -122,6 +130,7 @@ export interface NavigationAppearance {
122
130
  fontStyle?: TextStyle['fontStyle'];
123
131
  color?: TextStyle['color'];
124
132
  activeColor?: TextStyle['color'];
133
+ activeFontSize?: TextStyle['fontSize'];
125
134
  };
126
135
  };
127
136
  screen?: StyleProp<ViewStyle>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigmela/router",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "React Native Router",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
@@ -71,8 +71,8 @@
71
71
  "@eslint/eslintrc": "^3.3.1",
72
72
  "@eslint/js": "^9.35.0",
73
73
  "@evilmartians/lefthook": "^1.12.3",
74
- "@react-native/babel-preset": "0.81.1",
75
- "@react-native/eslint-config": "^0.81.1",
74
+ "@react-native/babel-preset": "0.83.2",
75
+ "@react-native/eslint-config": "^0.83.2",
76
76
  "@release-it/conventional-changelog": "^10.0.1",
77
77
  "@sigmela/native-sheet": "^0.0.1",
78
78
  "@types/jest": "^29.5.14",
@@ -83,12 +83,13 @@
83
83
  "eslint-config-prettier": "^10.1.8",
84
84
  "eslint-plugin-prettier": "^5.5.4",
85
85
  "jest": "^29.7.0",
86
- "prettier": "^3.6.2",
87
- "react": "19.1.0",
88
- "react-native": "0.81.4",
89
- "react-native-builder-bob": "^0.40.13",
86
+ "prettier": "^3.8.1",
87
+ "react": "19.2.0",
88
+ "react-native": "0.83.2",
89
+ "react-native-builder-bob": "^0.40.18",
90
90
  "react-native-reanimated": "^4.2.2",
91
- "react-native-screens": "^4.18.0",
91
+ "react-native-safe-area-context": "^5.7.0",
92
+ "react-native-screens": "^4.24.0",
92
93
  "react-native-worklets": "^0.7.4",
93
94
  "release-it": "^19.0.4",
94
95
  "typescript": "^5.9.2"
@@ -98,6 +99,7 @@
98
99
  "react": "*",
99
100
  "react-native": ">=0.72.0",
100
101
  "react-native-reanimated": ">=3.0.0",
102
+ "react-native-safe-area-context": ">=4.0.0",
101
103
  "react-native-screens": ">=4.18.0"
102
104
  },
103
105
  "peerDependenciesMeta": {
@@ -106,6 +108,9 @@
106
108
  },
107
109
  "react-native-reanimated": {
108
110
  "optional": true
111
+ },
112
+ "react-native-safe-area-context": {
113
+ "optional": true
109
114
  }
110
115
  },
111
116
  "workspaces": [
@@ -182,6 +187,6 @@
182
187
  "nanoid": "^5.1.6",
183
188
  "path-to-regexp": "^8.3.0",
184
189
  "query-string": "^9.3.1",
185
- "react-transition-state": "^2.3.1"
190
+ "react-transition-state": "^2.3.3"
186
191
  }
187
192
  }