@sigmela/router 0.2.2 → 0.2.4

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.
@@ -1,85 +1,78 @@
1
1
  "use strict";
2
2
 
3
- import { StackRenderer } from "../StackRenderer.js";
3
+ import { ScreenStackItem } from "../ScreenStackItem/index.js";
4
+ import { ScreenStack } from "../ScreenStack/index.js";
4
5
  import { SplitViewContext } from "./SplitViewContext.js";
5
6
  import { useRouter } from "../RouterContext.js";
6
- import { memo, useCallback, useSyncExternalStore } from 'react';
7
- import { StyleSheet, View } from 'react-native';
8
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
- const StackSliceRenderer = /*#__PURE__*/memo(({
10
- stack,
11
- appearance,
12
- fallbackToFirstRoute
13
- }) => {
14
- const router = useRouter();
15
- const stackId = stack.getId();
16
- const subscribe = useCallback(cb => router.subscribeStack(stackId, cb), [router, stackId]);
17
- const get = useCallback(() => router.getStackHistory(stackId), [router, stackId]);
18
- const history = useSyncExternalStore(subscribe, get, get);
19
- let historyToRender = history;
20
- if (fallbackToFirstRoute && historyToRender.length === 0) {
21
- const first = stack.getFirstRoute();
22
- if (first) {
23
- const activePath = router.getActiveRoute()?.path;
24
- historyToRender = [{
25
- key: `splitview-seed-${stackId}`,
26
- routeId: first.routeId,
27
- component: first.component,
28
- options: first.options,
29
- stackId,
30
- pattern: first.path,
31
- path: activePath ?? first.path
32
- }];
33
- }
34
- }
35
- return /*#__PURE__*/_jsx(StackRenderer, {
36
- appearance: appearance,
37
- stack: stack,
38
- history: historyToRender
39
- });
40
- });
41
- StackSliceRenderer.displayName = 'SplitViewStackSliceRendererNative';
7
+ import { memo, useCallback, useSyncExternalStore, useMemo } from 'react';
8
+ import { StyleSheet } from 'react-native';
9
+ import { jsx as _jsx } from "react/jsx-runtime";
10
+ /**
11
+ * On native (iPhone), SplitView renders primary and secondary screens
12
+ * in a SINGLE ScreenStack to get native push/pop animations.
13
+ *
14
+ * The combined history is: [...primaryHistory, ...secondaryHistory]
15
+ * This way, navigating from primary to secondary is a native push.
16
+ */
42
17
  export const RenderSplitView = /*#__PURE__*/memo(({
43
18
  splitView,
44
19
  appearance
45
20
  }) => {
46
21
  const router = useRouter();
22
+
23
+ // Subscribe to primary stack
24
+ 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
47
30
  const secondaryId = splitView.secondary.getId();
48
- const subscribe = useCallback(cb => router.subscribeStack(secondaryId, cb), [router, secondaryId]);
49
- const get = useCallback(() => router.getStackHistory(secondaryId), [router, secondaryId]);
50
- const secondaryHistory = useSyncExternalStore(subscribe, get, get);
51
- const hasSecondary = secondaryHistory.length > 0;
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);
34
+
35
+ // Fallback: if primary is empty, seed with first route
36
+ const primaryHistoryToRender = useMemo(() => {
37
+ if (primaryHistory.length > 0) {
38
+ return primaryHistory;
39
+ }
40
+ const first = splitView.primary.getFirstRoute();
41
+ if (!first) return [];
42
+ const activePath = router.getActiveRoute()?.path;
43
+ return [{
44
+ key: `splitview-seed-${primaryId}`,
45
+ routeId: first.routeId,
46
+ component: first.component,
47
+ options: first.options,
48
+ stackId: primaryId,
49
+ pattern: first.path,
50
+ path: activePath ?? first.path
51
+ }];
52
+ }, [primaryHistory, splitView.primary, primaryId, router]);
53
+
54
+ // Combine histories: primary screens first, then secondary screens on top
55
+ // This gives native push animation when navigating from primary to secondary
56
+ const combinedHistory = useMemo(() => {
57
+ return [...primaryHistoryToRender, ...secondaryHistory];
58
+ }, [primaryHistoryToRender, secondaryHistory]);
59
+
60
+ // Use primary stack ID for the combined ScreenStack
61
+ // (secondary items will animate as if pushed onto this stack)
52
62
  return /*#__PURE__*/_jsx(SplitViewContext.Provider, {
53
63
  value: splitView,
54
- children: /*#__PURE__*/_jsxs(View, {
64
+ children: /*#__PURE__*/_jsx(ScreenStack, {
55
65
  style: styles.container,
56
- children: [/*#__PURE__*/_jsx(View, {
57
- style: styles.primary,
58
- pointerEvents: hasSecondary ? 'none' : 'auto',
59
- children: /*#__PURE__*/_jsx(StackSliceRenderer, {
60
- appearance: appearance,
61
- stack: splitView.primary,
62
- fallbackToFirstRoute: true
63
- })
64
- }), hasSecondary ? /*#__PURE__*/_jsx(View, {
65
- style: styles.secondary,
66
- children: /*#__PURE__*/_jsx(StackSliceRenderer, {
67
- appearance: appearance,
68
- stack: splitView.secondary
69
- })
70
- }) : null]
66
+ children: combinedHistory.map(item => /*#__PURE__*/_jsx(ScreenStackItem, {
67
+ appearance: appearance,
68
+ stackId: item.stackId,
69
+ item: item
70
+ }, `splitview-${item.key}`))
71
71
  })
72
72
  });
73
73
  });
74
74
  const styles = StyleSheet.create({
75
75
  container: {
76
76
  flex: 1
77
- },
78
- primary: {
79
- flex: 1
80
- },
81
- secondary: {
82
- ...StyleSheet.absoluteFillObject,
83
- zIndex: 2
84
77
  }
85
78
  });
@@ -5,7 +5,7 @@ import { TabBarContext } from "./TabBarContext.js";
5
5
  import { useRouter } from "../RouterContext.js";
6
6
  import { BottomTabsScreen, BottomTabs, ScreenStackItem } from 'react-native-screens';
7
7
  import { Platform, StyleSheet, View } from 'react-native';
8
- import { useCallback, useSyncExternalStore, memo, useEffect, useState } from 'react';
8
+ import { useCallback, useSyncExternalStore, memo, useEffect, useState, useMemo } from 'react';
9
9
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
10
10
  const isImageSource = value => {
11
11
  if (value == null) return false;
@@ -146,10 +146,21 @@ const TabStackRenderer = /*#__PURE__*/memo(({
146
146
  });
147
147
  });
148
148
  TabStackRenderer.displayName = 'TabStackRenderer';
149
+ const TabNodeRenderer = /*#__PURE__*/memo(({
150
+ node,
151
+ appearance
152
+ }) => {
153
+ const Renderer = useMemo(() => node.getRenderer(), [node]);
154
+ return /*#__PURE__*/_jsx(Renderer, {
155
+ appearance: appearance
156
+ });
157
+ });
158
+ TabNodeRenderer.displayName = 'TabNodeRenderer';
149
159
  export const RenderTabBar = /*#__PURE__*/memo(({
150
160
  tabBar,
151
161
  appearance = {}
152
162
  }) => {
163
+ const router = useRouter();
153
164
  const subscribe = useCallback(cb => tabBar.subscribe(cb), [tabBar]);
154
165
  const snapshot = useSyncExternalStore(subscribe, tabBar.getState, tabBar.getState);
155
166
  const {
@@ -172,11 +183,82 @@ export const RenderTabBar = /*#__PURE__*/memo(({
172
183
  const onNativeFocusChange = useCallback(event => {
173
184
  const tabKey = event.nativeEvent.tabKey;
174
185
  const tabIndex = tabs.findIndex(route => route.tabKey === tabKey);
175
- tabBar.onIndexChange(tabIndex);
176
- }, [tabs, tabBar]);
186
+ 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
+
192
+ // Update TabBar UI state
193
+ if (tabIndex !== index) {
194
+ tabBar.onIndexChange(tabIndex);
195
+ }
196
+
197
+ // Navigate to the target stack's first route if needed
198
+ if (targetStack) {
199
+ const stackId = targetStack.getId();
200
+ const stackHistory = router.getStackHistory(stackId);
201
+ // Only navigate if stack is empty (first visit)
202
+ if (stackHistory.length === 0) {
203
+ const firstRoute = targetStack.getFirstRoute();
204
+ if (firstRoute?.path) {
205
+ router.navigate(firstRoute.path);
206
+ }
207
+ }
208
+ } else if (targetNode) {
209
+ // For nodes like SplitView, check if we need to seed it
210
+ 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
+ }
220
+ }
221
+ }
222
+ }
223
+ }, [tabs, tabBar, index, router]);
177
224
  const onTabPress = useCallback(nextIndex => {
178
- tabBar.onIndexChange(nextIndex);
179
- }, [tabBar]);
225
+ const targetTab = tabs[nextIndex];
226
+ if (!targetTab) return;
227
+ const targetStack = tabBar.stacks[targetTab.tabKey];
228
+ const targetNode = tabBar.nodes[targetTab.tabKey];
229
+
230
+ // Update TabBar UI state
231
+ if (nextIndex !== index) {
232
+ tabBar.onIndexChange(nextIndex);
233
+ }
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]);
180
262
  const containerProps = {
181
263
  tabBarBackgroundColor: backgroundColor,
182
264
  tabBarItemTitleFontFamily: title?.fontFamily,
@@ -245,12 +327,16 @@ export const RenderTabBar = /*#__PURE__*/memo(({
245
327
  children: tabs.filter(t => visited[t.tabKey]).map(tab => {
246
328
  const isActive = tab.tabKey === tabs[index]?.tabKey;
247
329
  const stackForTab = tabBar.stacks[tab.tabKey];
330
+ const nodeForTab = tabBar.nodes[tab.tabKey];
248
331
  const ScreenForTab = tabBar.screens[tab.tabKey];
249
332
  return /*#__PURE__*/_jsx(View, {
250
333
  style: [styles.flex, !isActive && styles.hidden],
251
334
  children: stackForTab ? /*#__PURE__*/_jsx(TabStackRenderer, {
252
335
  appearance: appearance,
253
336
  stack: stackForTab
337
+ }) : nodeForTab ? /*#__PURE__*/_jsx(TabNodeRenderer, {
338
+ appearance: appearance,
339
+ node: nodeForTab
254
340
  }) : ScreenForTab ? /*#__PURE__*/_jsx(ScreenForTab, {}) : null
255
341
  }, `tab-content-${tab.tabKey}`);
256
342
  })
@@ -277,6 +363,7 @@ export const RenderTabBar = /*#__PURE__*/memo(({
277
363
  children: tabs.map(tab => {
278
364
  const isFocused = tab.tabKey === tabs[index]?.tabKey;
279
365
  const stack = tabBar.stacks[tab.tabKey];
366
+ const node = tabBar.nodes[tab.tabKey];
280
367
  const Screen = tabBar.screens[tab.tabKey];
281
368
  const icon = getTabIcon(tab);
282
369
  return /*#__PURE__*/_jsx(BottomTabsScreen, {
@@ -292,6 +379,9 @@ export const RenderTabBar = /*#__PURE__*/memo(({
292
379
  children: stack ? /*#__PURE__*/_jsx(TabStackRenderer, {
293
380
  appearance: appearance,
294
381
  stack: stack
382
+ }) : node ? /*#__PURE__*/_jsx(TabNodeRenderer, {
383
+ appearance: appearance,
384
+ node: node
295
385
  }) : Screen ? /*#__PURE__*/_jsx(Screen, {}) : null
296
386
  }, tab.tabKey);
297
387
  })
@@ -34,6 +34,29 @@ const TabStackRenderer = /*#__PURE__*/memo(({
34
34
  });
35
35
  });
36
36
  TabStackRenderer.displayName = 'TabStackRenderer';
37
+ const TabNodeRenderer = /*#__PURE__*/memo(({
38
+ node,
39
+ appearance
40
+ }) => {
41
+ const Renderer = useMemo(() => node.getRenderer(), [node]);
42
+ return /*#__PURE__*/_jsx(Renderer, {
43
+ appearance: appearance
44
+ });
45
+ });
46
+ TabNodeRenderer.displayName = 'TabNodeRenderer';
47
+ const joinPrefixAndPath = (prefix, path) => {
48
+ const base = (prefix ?? '').trim();
49
+ const child = (path || '/').trim();
50
+ if (!base) return child || '/';
51
+ const baseNorm = base === '/' ? '' : base.endsWith('/') ? base.slice(0, -1) : base;
52
+ if (!child || child === '/') {
53
+ return baseNorm || '/';
54
+ }
55
+ if (child.startsWith('/')) {
56
+ return `${baseNorm}${child}`;
57
+ }
58
+ return `${baseNorm}/${child}`;
59
+ };
37
60
  export const RenderTabBar = /*#__PURE__*/memo(({
38
61
  tabBar,
39
62
  appearance
@@ -48,11 +71,13 @@ export const RenderTabBar = /*#__PURE__*/memo(({
48
71
  } = snapshot;
49
72
  const focusedTab = tabs[index];
50
73
  const stack = focusedTab ? tabBar.stacks[focusedTab.tabKey] : undefined;
74
+ const node = focusedTab ? tabBar.nodes[focusedTab.tabKey] : undefined;
51
75
  const Screen = focusedTab ? tabBar.screens[focusedTab.tabKey] : undefined;
52
76
  const onTabClick = useCallback(nextIndex => {
53
77
  const targetTab = tabs[nextIndex];
54
78
  if (!targetTab) return;
55
79
  const targetStack = tabBar.stacks[targetTab.tabKey];
80
+ const targetNode = tabBar.nodes[targetTab.tabKey];
56
81
  if (targetStack) {
57
82
  // Keep TabBar UI in sync immediately.
58
83
  if (nextIndex !== index) {
@@ -65,6 +90,17 @@ export const RenderTabBar = /*#__PURE__*/memo(({
65
90
  router.reset(firstRoutePath);
66
91
  return;
67
92
  }
93
+ } else if (targetNode) {
94
+ // Keep TabBar UI in sync immediately.
95
+ if (nextIndex !== index) {
96
+ tabBar.onIndexChange(nextIndex);
97
+ }
98
+ const seedPath = targetNode.seed?.()?.path;
99
+ const fallbackFirstPath = targetNode.getNodeRoutes()?.[0]?.path;
100
+ const path = seedPath ?? fallbackFirstPath ?? '/';
101
+ const fullPath = joinPrefixAndPath(targetTab.tabPrefix, path);
102
+ router.reset(fullPath);
103
+ return;
68
104
  }
69
105
  if (nextIndex !== index) {
70
106
  tabBar.onIndexChange(nextIndex);
@@ -97,6 +133,9 @@ export const RenderTabBar = /*#__PURE__*/memo(({
97
133
  children: [stack ? /*#__PURE__*/_jsx(TabStackRenderer, {
98
134
  appearance: appearance,
99
135
  stack: stack
136
+ }) : node ? /*#__PURE__*/_jsx(TabNodeRenderer, {
137
+ appearance: appearance,
138
+ node: node
100
139
  }) : Screen ? /*#__PURE__*/_jsx(Screen, {}) : null, CustomTabBar ? /*#__PURE__*/_jsx(CustomTabBar, {
101
140
  onTabPress: onTabClick,
102
141
  activeIndex: index,
@@ -8,6 +8,7 @@ import { RenderTabBar } from './RenderTabBar';
8
8
  export class TabBar {
9
9
  screens = {};
10
10
  stacks = {};
11
+ nodes = {};
11
12
  listeners = new Set();
12
13
  constructor(options = {}) {
13
14
  this.tabBarId = `tabbar-${Math.random().toString(36).slice(2)}`;
@@ -23,6 +24,10 @@ export class TabBar {
23
24
  return this.tabBarId;
24
25
  }
25
26
  addTab(tab) {
27
+ const sourcesCount = (tab.stack ? 1 : 0) + (tab.node ? 1 : 0) + (tab.screen ? 1 : 0);
28
+ if (sourcesCount !== 1) {
29
+ throw new Error(`TabBar.addTab: exactly one of { stack, node, screen } must be provided (got ${sourcesCount})`);
30
+ }
26
31
  const {
27
32
  key,
28
33
  ...rest
@@ -31,6 +36,7 @@ export class TabBar {
31
36
  const tabKey = key ?? `tab-${nextIndex}`;
32
37
  const nextTabs = [...this.state.tabs, {
33
38
  tabKey,
39
+ tabPrefix: tab.prefix,
34
40
  ...rest
35
41
  }];
36
42
  this.setState({
@@ -38,6 +44,8 @@ export class TabBar {
38
44
  });
39
45
  if (tab.stack) {
40
46
  this.stacks[tabKey] = tab.stack;
47
+ } else if (tab.node) {
48
+ this.nodes[tabKey] = tab.node;
41
49
  } else if (tab.screen) {
42
50
  this.screens[tabKey] = tab.screen;
43
51
  }
@@ -102,8 +110,8 @@ export class TabBar {
102
110
  getActiveChildId() {
103
111
  const activeTab = this.state.tabs[this.state.index];
104
112
  if (!activeTab) return undefined;
105
- const stack = this.stacks[activeTab.tabKey];
106
- return stack?.getId();
113
+ const node = this.nodes[activeTab.tabKey] ?? this.stacks[activeTab.tabKey];
114
+ return node?.getId();
107
115
  }
108
116
  switchToRoute(routeId) {
109
117
  const idx = this.findTabIndexByRoute(routeId);
@@ -126,11 +134,11 @@ export class TabBar {
126
134
  const children = [];
127
135
  for (let idx = 0; idx < this.state.tabs.length; idx++) {
128
136
  const tab = this.state.tabs[idx];
129
- const stack = tab ? this.stacks[tab.tabKey] : undefined;
130
- if (stack) {
137
+ const node = tab ? this.nodes[tab.tabKey] ?? this.stacks[tab.tabKey] : undefined;
138
+ if (node) {
131
139
  children.push({
132
- prefix: '',
133
- node: stack,
140
+ prefix: tab?.tabPrefix ?? '',
141
+ node,
134
142
  onMatch: () => this.onIndexChange(idx)
135
143
  });
136
144
  }
@@ -150,6 +158,10 @@ export class TabBar {
150
158
  seed() {
151
159
  const activeTab = this.state.tabs[this.state.index];
152
160
  if (!activeTab) return null;
161
+ const node = this.nodes[activeTab.tabKey];
162
+ if (node) {
163
+ return node.seed?.() ?? null;
164
+ }
153
165
  const stack = this.stacks[activeTab.tabKey];
154
166
  if (!stack) return null;
155
167
  const firstRoute = stack.getFirstRoute();
@@ -163,9 +175,9 @@ export class TabBar {
163
175
  findTabIndexByRoute(routeId) {
164
176
  for (let i = 0; i < this.state.tabs.length; i++) {
165
177
  const tab = this.state.tabs[i];
166
- const stack = tab && this.stacks[tab.tabKey];
167
- if (!stack) continue;
168
- const hasRoute = this.nodeHasRoute(stack, routeId);
178
+ const node = tab ? this.nodes[tab.tabKey] ?? this.stacks[tab.tabKey] : undefined;
179
+ if (!node) continue;
180
+ const hasRoute = this.nodeHasRoute(node, routeId);
169
181
  if (hasRoute) {
170
182
  return i;
171
183
  }