@sigmela/router 0.2.1 → 0.2.3
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 +5 -4
- package/lib/module/Navigation.js +1 -1
- package/lib/module/Router.js +32 -2
- package/lib/module/TabBar/RenderTabBar.native.js +19 -1
- package/lib/module/TabBar/RenderTabBar.web.js +94 -34
- package/lib/module/TabBar/TabBar.js +21 -9
- package/lib/module/styles.css +139 -19
- package/lib/typescript/src/Router.d.ts +6 -2
- package/lib/typescript/src/TabBar/TabBar.d.ts +23 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -72,7 +72,7 @@ const rootStack = new NavigationStack()
|
|
|
72
72
|
.addScreen('/', HomeScreen, { header: { title: 'Home' } })
|
|
73
73
|
.addScreen('/details/:id', DetailsScreen, { header: { title: 'Details' } });
|
|
74
74
|
|
|
75
|
-
const router = new Router({
|
|
75
|
+
const router = new Router({ roots: { app: rootStack }, root: 'app' });
|
|
76
76
|
|
|
77
77
|
export default function App() {
|
|
78
78
|
return <Navigation router={router} />;
|
|
@@ -94,7 +94,7 @@ const tabBar = new TabBar({ initialIndex: 0 })
|
|
|
94
94
|
.addTab({ key: 'home', stack: homeStack, title: 'Home' })
|
|
95
95
|
.addTab({ key: 'catalog', stack: catalogStack, title: 'Catalog' });
|
|
96
96
|
|
|
97
|
-
const router = new Router({
|
|
97
|
+
const router = new Router({ roots: { app: tabBar }, root: 'app' });
|
|
98
98
|
|
|
99
99
|
export default function App() {
|
|
100
100
|
return <Navigation router={router} />;
|
|
@@ -158,7 +158,8 @@ The `Router` holds navigation state and performs path matching.
|
|
|
158
158
|
|
|
159
159
|
```ts
|
|
160
160
|
const router = new Router({
|
|
161
|
-
root, // NavigationNode (NavigationStack, TabBar, SplitView, ...)
|
|
161
|
+
roots: { app: root }, // NavigationNode (NavigationStack, TabBar, SplitView, ...)
|
|
162
|
+
root: 'app',
|
|
162
163
|
screenOptions, // optional defaults
|
|
163
164
|
debug, // optional
|
|
164
165
|
});
|
|
@@ -169,7 +170,7 @@ Navigation:
|
|
|
169
170
|
- `router.replace(path, dedupe?)` — replace top of the active stack
|
|
170
171
|
- `router.goBack()` — pop top of the active stack
|
|
171
172
|
- `router.reset(path)` — **web-only**: rebuild Router state as if app loaded at `path`
|
|
172
|
-
- `router.setRoot(
|
|
173
|
+
- `router.setRoot(rootKey, { transition? })` — swap root at runtime (`rootKey` from `config.roots`)
|
|
173
174
|
|
|
174
175
|
State/subscriptions:
|
|
175
176
|
- `router.getState()` → `{ history: HistoryItem[] }`
|
package/lib/module/Navigation.js
CHANGED
package/lib/module/Router.js
CHANGED
|
@@ -27,6 +27,9 @@ export class Router {
|
|
|
27
27
|
activeRoute = null;
|
|
28
28
|
rootListeners = new Set();
|
|
29
29
|
rootTransition = undefined;
|
|
30
|
+
// Root swaps should behave like a fresh initial mount (no enter animation).
|
|
31
|
+
// We keep the API option for compatibility, but suppress transition application.
|
|
32
|
+
suppressRootTransitionOnNextRead = false;
|
|
30
33
|
lastBrowserIndex = 0;
|
|
31
34
|
suppressHistorySyncCount = 0;
|
|
32
35
|
|
|
@@ -36,7 +39,13 @@ export class Router {
|
|
|
36
39
|
this.debugEnabled = config.debug ?? false;
|
|
37
40
|
this.routerScreenOptions = config.screenOptions;
|
|
38
41
|
this.log('ctor');
|
|
39
|
-
this.
|
|
42
|
+
this.roots = config.roots;
|
|
43
|
+
this.activeRootKey = config.root;
|
|
44
|
+
const initialRoot = this.roots[this.activeRootKey];
|
|
45
|
+
if (!initialRoot) {
|
|
46
|
+
throw new Error(`Router: root "${String(this.activeRootKey)}" not found in config.roots`);
|
|
47
|
+
}
|
|
48
|
+
this.root = initialRoot;
|
|
40
49
|
this.buildRegistry();
|
|
41
50
|
if (this.isWebEnv()) {
|
|
42
51
|
this.setupBrowserHistory();
|
|
@@ -174,11 +183,24 @@ export class Router {
|
|
|
174
183
|
this.rootListeners.forEach(l => l());
|
|
175
184
|
}
|
|
176
185
|
getRootTransition() {
|
|
186
|
+
if (this.suppressRootTransitionOnNextRead) {
|
|
187
|
+
this.suppressRootTransitionOnNextRead = false;
|
|
188
|
+
// Ensure we don't accidentally apply it on subsequent renders.
|
|
189
|
+
this.rootTransition = undefined;
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
177
192
|
return this.rootTransition;
|
|
178
193
|
}
|
|
179
|
-
setRoot(
|
|
194
|
+
setRoot(nextRootKey, options) {
|
|
195
|
+
const nextRoot = this.roots[nextRootKey];
|
|
196
|
+
if (!nextRoot) {
|
|
197
|
+
throw new Error(`Router: root "${String(nextRootKey)}" not found in config.roots`);
|
|
198
|
+
}
|
|
199
|
+
this.activeRootKey = nextRootKey;
|
|
180
200
|
this.root = nextRoot;
|
|
181
201
|
this.rootTransition = options?.transition ?? undefined;
|
|
202
|
+
// Make the incoming root behave like initial: suppress enter animation.
|
|
203
|
+
this.suppressRootTransitionOnNextRead = true;
|
|
182
204
|
this.registry.length = 0;
|
|
183
205
|
this.stackById.clear();
|
|
184
206
|
this.routeById.clear();
|
|
@@ -345,6 +367,14 @@ export class Router {
|
|
|
345
367
|
}
|
|
346
368
|
return;
|
|
347
369
|
}
|
|
370
|
+
|
|
371
|
+
// Ensure the root-level container (e.g. TabBar) is activated for the matched route.
|
|
372
|
+
// This is important when the matched route belongs to a nested stack inside a composite node
|
|
373
|
+
// like SplitView: stackActivators won't fire for the tab itself in that case.
|
|
374
|
+
const rootStackId = this.root?.getId();
|
|
375
|
+
if (rootStackId) {
|
|
376
|
+
this.activateContainerForRoute(base.routeId, rootStackId);
|
|
377
|
+
}
|
|
348
378
|
const activator = base.stackId ? this.stackActivators.get(base.stackId) : undefined;
|
|
349
379
|
if (activator) {
|
|
350
380
|
activator();
|
|
@@ -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,6 +146,16 @@ 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 = {}
|
|
@@ -245,12 +255,16 @@ export const RenderTabBar = /*#__PURE__*/memo(({
|
|
|
245
255
|
children: tabs.filter(t => visited[t.tabKey]).map(tab => {
|
|
246
256
|
const isActive = tab.tabKey === tabs[index]?.tabKey;
|
|
247
257
|
const stackForTab = tabBar.stacks[tab.tabKey];
|
|
258
|
+
const nodeForTab = tabBar.nodes[tab.tabKey];
|
|
248
259
|
const ScreenForTab = tabBar.screens[tab.tabKey];
|
|
249
260
|
return /*#__PURE__*/_jsx(View, {
|
|
250
261
|
style: [styles.flex, !isActive && styles.hidden],
|
|
251
262
|
children: stackForTab ? /*#__PURE__*/_jsx(TabStackRenderer, {
|
|
252
263
|
appearance: appearance,
|
|
253
264
|
stack: stackForTab
|
|
265
|
+
}) : nodeForTab ? /*#__PURE__*/_jsx(TabNodeRenderer, {
|
|
266
|
+
appearance: appearance,
|
|
267
|
+
node: nodeForTab
|
|
254
268
|
}) : ScreenForTab ? /*#__PURE__*/_jsx(ScreenForTab, {}) : null
|
|
255
269
|
}, `tab-content-${tab.tabKey}`);
|
|
256
270
|
})
|
|
@@ -277,6 +291,7 @@ export const RenderTabBar = /*#__PURE__*/memo(({
|
|
|
277
291
|
children: tabs.map(tab => {
|
|
278
292
|
const isFocused = tab.tabKey === tabs[index]?.tabKey;
|
|
279
293
|
const stack = tabBar.stacks[tab.tabKey];
|
|
294
|
+
const node = tabBar.nodes[tab.tabKey];
|
|
280
295
|
const Screen = tabBar.screens[tab.tabKey];
|
|
281
296
|
const icon = getTabIcon(tab);
|
|
282
297
|
return /*#__PURE__*/_jsx(BottomTabsScreen, {
|
|
@@ -292,6 +307,9 @@ export const RenderTabBar = /*#__PURE__*/memo(({
|
|
|
292
307
|
children: stack ? /*#__PURE__*/_jsx(TabStackRenderer, {
|
|
293
308
|
appearance: appearance,
|
|
294
309
|
stack: stack
|
|
310
|
+
}) : node ? /*#__PURE__*/_jsx(TabNodeRenderer, {
|
|
311
|
+
appearance: appearance,
|
|
312
|
+
node: node
|
|
295
313
|
}) : Screen ? /*#__PURE__*/_jsx(Screen, {}) : null
|
|
296
314
|
}, tab.tabKey);
|
|
297
315
|
})
|
|
@@ -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);
|
|
@@ -72,10 +108,17 @@ export const RenderTabBar = /*#__PURE__*/memo(({
|
|
|
72
108
|
}, [router, tabBar, tabs, index]);
|
|
73
109
|
const tabBarStyle = useMemo(() => {
|
|
74
110
|
const tabBarBg = toColorString(appearance?.tabBar?.backgroundColor);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
111
|
+
const style = {
|
|
112
|
+
...(tabBarBg ? {
|
|
113
|
+
['--tabbar-bg']: tabBarBg
|
|
114
|
+
} : null),
|
|
115
|
+
...(tabs.length ? {
|
|
116
|
+
['--tabbar-tabs-count']: String(tabs.length),
|
|
117
|
+
['--tabbar-active-index']: String(index)
|
|
118
|
+
} : null)
|
|
119
|
+
};
|
|
120
|
+
return Object.keys(style).length ? style : undefined;
|
|
121
|
+
}, [appearance?.tabBar?.backgroundColor, tabs.length, index]);
|
|
79
122
|
const titleBaseStyle = useMemo(() => ({
|
|
80
123
|
fontFamily: appearance?.tabBar?.title?.fontFamily,
|
|
81
124
|
fontSize: appearance?.tabBar?.title?.fontSize,
|
|
@@ -90,6 +133,9 @@ export const RenderTabBar = /*#__PURE__*/memo(({
|
|
|
90
133
|
children: [stack ? /*#__PURE__*/_jsx(TabStackRenderer, {
|
|
91
134
|
appearance: appearance,
|
|
92
135
|
stack: stack
|
|
136
|
+
}) : node ? /*#__PURE__*/_jsx(TabNodeRenderer, {
|
|
137
|
+
appearance: appearance,
|
|
138
|
+
node: node
|
|
93
139
|
}) : Screen ? /*#__PURE__*/_jsx(Screen, {}) : null, CustomTabBar ? /*#__PURE__*/_jsx(CustomTabBar, {
|
|
94
140
|
onTabPress: onTabClick,
|
|
95
141
|
activeIndex: index,
|
|
@@ -97,37 +143,51 @@ export const RenderTabBar = /*#__PURE__*/memo(({
|
|
|
97
143
|
}) : /*#__PURE__*/_jsx("div", {
|
|
98
144
|
className: "tab-bar",
|
|
99
145
|
style: tabBarStyle,
|
|
100
|
-
|
|
146
|
+
"data-tabs-count": tabs.length,
|
|
147
|
+
"data-active-index": index,
|
|
148
|
+
children: /*#__PURE__*/_jsxs("div", {
|
|
101
149
|
className: "tab-bar-inner",
|
|
102
|
-
children:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
150
|
+
children: [/*#__PURE__*/_jsx("div", {
|
|
151
|
+
className: "tab-bar-glass",
|
|
152
|
+
"aria-hidden": "true"
|
|
153
|
+
}), /*#__PURE__*/_jsxs("div", {
|
|
154
|
+
className: "tab-bar-content",
|
|
155
|
+
children: [/*#__PURE__*/_jsx("div", {
|
|
156
|
+
className: "tab-bar-active-indicator",
|
|
157
|
+
"aria-hidden": "true"
|
|
158
|
+
}), tabs.map((tab, i) => {
|
|
159
|
+
const isActive = i === index;
|
|
160
|
+
const iconTint = toColorString(isActive ? appearance?.tabBar?.iconColorActive : appearance?.tabBar?.iconColor);
|
|
161
|
+
const title = appearance?.tabBar?.title;
|
|
162
|
+
const labelColor = isActive ? toColorString(title?.activeColor) ?? toColorString(title?.color) : toColorString(title?.color);
|
|
163
|
+
const labelStyle = {
|
|
164
|
+
...titleBaseStyle,
|
|
165
|
+
color: labelColor
|
|
166
|
+
};
|
|
167
|
+
return /*#__PURE__*/_jsxs("button", {
|
|
168
|
+
type: "button",
|
|
169
|
+
"data-index": i,
|
|
170
|
+
"data-active": isActive ? 'true' : 'false',
|
|
171
|
+
"aria-current": isActive ? 'page' : undefined,
|
|
172
|
+
className: `tab-item${isActive ? ' active' : ''}`,
|
|
173
|
+
onClick: () => onTabClick(i),
|
|
174
|
+
children: [/*#__PURE__*/_jsx("div", {
|
|
175
|
+
className: "tab-item-icon",
|
|
176
|
+
children: isImageSource(tab.icon) ? /*#__PURE__*/_jsx(TabIcon, {
|
|
177
|
+
source: tab.icon,
|
|
178
|
+
tintColor: iconTint
|
|
179
|
+
}) : null
|
|
180
|
+
}), /*#__PURE__*/_jsx("div", {
|
|
181
|
+
className: "tab-item-label",
|
|
182
|
+
style: labelStyle,
|
|
183
|
+
children: tab.title
|
|
184
|
+
}), tab.badgeValue ? /*#__PURE__*/_jsx("span", {
|
|
185
|
+
className: "tab-item-label-badge",
|
|
186
|
+
children: tab.badgeValue
|
|
187
|
+
}) : null]
|
|
188
|
+
}, tab.tabKey);
|
|
189
|
+
})]
|
|
190
|
+
})]
|
|
131
191
|
})
|
|
132
192
|
})]
|
|
133
193
|
})
|
|
@@ -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
|
|
106
|
-
return
|
|
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
|
|
130
|
-
if (
|
|
137
|
+
const node = tab ? this.nodes[tab.tabKey] ?? this.stacks[tab.tabKey] : undefined;
|
|
138
|
+
if (node) {
|
|
131
139
|
children.push({
|
|
132
|
-
prefix: '',
|
|
133
|
-
node
|
|
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
|
|
167
|
-
if (!
|
|
168
|
-
const hasRoute = this.nodeHasRoute(
|
|
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
|
}
|
package/lib/module/styles.css
CHANGED
|
@@ -489,10 +489,25 @@
|
|
|
489
489
|
position: relative;
|
|
490
490
|
}
|
|
491
491
|
|
|
492
|
-
.tab-stacks-container > .screen-stack
|
|
492
|
+
.tab-stacks-container > .screen-stack,
|
|
493
|
+
.tab-stacks-container > .split-view-container {
|
|
493
494
|
padding-bottom: calc(73px + env(safe-area-inset-bottom));
|
|
494
495
|
}
|
|
495
496
|
|
|
497
|
+
.tab-bar {
|
|
498
|
+
/* CSS variables for theming / behavior */
|
|
499
|
+
--tabbar-bg: #ffffff;
|
|
500
|
+
--tabbar-gap: 4px;
|
|
501
|
+
--tabbar-radius: 9999px;
|
|
502
|
+
--tabbar-padding: 6px;
|
|
503
|
+
--tabbar-item-radius: 9999px;
|
|
504
|
+
--tabbar-height: 56px;
|
|
505
|
+
|
|
506
|
+
/* Used by active indicator (set inline in RenderTabBar.web.tsx) */
|
|
507
|
+
--tabbar-tabs-count: 1;
|
|
508
|
+
--tabbar-active-index: 0;
|
|
509
|
+
}
|
|
510
|
+
|
|
496
511
|
.tab-bar-blur-overlay {
|
|
497
512
|
position: fixed;
|
|
498
513
|
bottom: 0;
|
|
@@ -516,23 +531,69 @@
|
|
|
516
531
|
bottom: 0;
|
|
517
532
|
left: 0;
|
|
518
533
|
right: 0;
|
|
519
|
-
padding:
|
|
520
|
-
padding-bottom: max(
|
|
534
|
+
padding: 10px 12px;
|
|
535
|
+
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
|
521
536
|
background: transparent;
|
|
522
537
|
z-index: 100;
|
|
523
538
|
}
|
|
524
539
|
|
|
525
540
|
.tab-bar-inner {
|
|
541
|
+
position: relative;
|
|
542
|
+
display: flex;
|
|
543
|
+
width: 100%;
|
|
544
|
+
max-width: 100%;
|
|
545
|
+
border-radius: var(--tabbar-radius);
|
|
546
|
+
overflow: hidden;
|
|
547
|
+
isolation: isolate;
|
|
548
|
+
width: 100%;
|
|
549
|
+
max-width: 100%;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
.tab-bar-glass {
|
|
553
|
+
position: absolute;
|
|
554
|
+
inset: 0;
|
|
555
|
+
z-index: 0;
|
|
556
|
+
pointer-events: none;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
.tab-bar-content {
|
|
560
|
+
position: relative;
|
|
561
|
+
z-index: 1;
|
|
526
562
|
display: flex;
|
|
527
563
|
flex-direction: row;
|
|
528
564
|
align-items: center;
|
|
529
|
-
background: #FFFFFF;
|
|
530
|
-
box-shadow: 0 2px 8px #00000014;
|
|
531
|
-
border-radius: 100px;
|
|
532
|
-
padding: 4px;
|
|
533
|
-
gap: 4px;
|
|
534
565
|
width: 100%;
|
|
535
566
|
max-width: 100%;
|
|
567
|
+
gap: var(--tabbar-gap);
|
|
568
|
+
padding: var(--tabbar-padding);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
.tab-bar-active-indicator {
|
|
572
|
+
position: absolute;
|
|
573
|
+
top: var(--tabbar-padding);
|
|
574
|
+
bottom: var(--tabbar-padding);
|
|
575
|
+
left: var(--tabbar-padding);
|
|
576
|
+
border-radius: var(--tabbar-item-radius);
|
|
577
|
+
z-index: 0;
|
|
578
|
+
pointer-events: none;
|
|
579
|
+
opacity: 0;
|
|
580
|
+
transition:
|
|
581
|
+
transform 220ms cubic-bezier(0.22, 0.61, 0.36, 1),
|
|
582
|
+
opacity 180ms cubic-bezier(0.22, 0.61, 0.36, 1);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
.tab-bar[data-tabs-count]:not([data-tabs-count="0"]) .tab-bar-active-indicator {
|
|
586
|
+
opacity: 1;
|
|
587
|
+
width: calc(
|
|
588
|
+
(
|
|
589
|
+
100% -
|
|
590
|
+
(var(--tabbar-padding) * 2) -
|
|
591
|
+
(var(--tabbar-gap) * (var(--tabbar-tabs-count) - 1))
|
|
592
|
+
) / var(--tabbar-tabs-count)
|
|
593
|
+
);
|
|
594
|
+
transform: translateX(
|
|
595
|
+
calc((var(--tabbar-active-index) * (100% + var(--tabbar-gap))))
|
|
596
|
+
);
|
|
536
597
|
}
|
|
537
598
|
|
|
538
599
|
.tab-item {
|
|
@@ -540,37 +601,44 @@
|
|
|
540
601
|
background: transparent;
|
|
541
602
|
border: 0;
|
|
542
603
|
margin: 0;
|
|
543
|
-
padding:
|
|
544
|
-
height:
|
|
604
|
+
padding: 6px 10px;
|
|
605
|
+
height: var(--tabbar-height);
|
|
545
606
|
color: inherit;
|
|
546
607
|
display: flex;
|
|
547
|
-
flex: 1;
|
|
608
|
+
flex: 1 1 0;
|
|
548
609
|
flex-direction: column;
|
|
549
610
|
align-items: center;
|
|
550
611
|
justify-content: center;
|
|
551
612
|
gap: 4px;
|
|
552
613
|
position: relative;
|
|
553
614
|
cursor: pointer;
|
|
554
|
-
border-radius:
|
|
555
|
-
transition:
|
|
615
|
+
border-radius: var(--tabbar-item-radius);
|
|
616
|
+
transition:
|
|
617
|
+
transform 120ms cubic-bezier(0.22, 0.61, 0.36, 1),
|
|
618
|
+
background-color 200ms cubic-bezier(0.22, 0.61, 0.36, 1),
|
|
619
|
+
color 200ms cubic-bezier(0.22, 0.61, 0.36, 1);
|
|
556
620
|
}
|
|
557
621
|
|
|
558
622
|
.tab-item.active {
|
|
559
|
-
background:
|
|
623
|
+
background: transparent;
|
|
560
624
|
}
|
|
561
625
|
|
|
562
626
|
.tab-item:hover:not(.active) {
|
|
563
|
-
background: color-mix(in srgb,
|
|
627
|
+
background: color-mix(in srgb, #0a0a0a 6%, transparent);
|
|
564
628
|
}
|
|
565
629
|
|
|
630
|
+
.tab-item:active {
|
|
631
|
+
transform: scale(0.98);
|
|
632
|
+
}
|
|
566
633
|
|
|
567
634
|
.tab-item:focus {
|
|
568
635
|
outline: none;
|
|
569
636
|
}
|
|
570
637
|
|
|
571
638
|
.tab-item:focus-visible {
|
|
572
|
-
|
|
573
|
-
|
|
639
|
+
box-shadow:
|
|
640
|
+
0 0 0 2px color-mix(in srgb, currentColor 40%, transparent),
|
|
641
|
+
0 10px 28px rgba(0, 0, 0, 0.12);
|
|
574
642
|
}
|
|
575
643
|
|
|
576
644
|
.tab-item-icon {
|
|
@@ -617,13 +685,49 @@
|
|
|
617
685
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
|
618
686
|
}
|
|
619
687
|
|
|
688
|
+
/* ==================== MOBILE TAB BAR (<= 640px) — iOS-like floating glass pill ==================== */
|
|
689
|
+
@media (max-width: 640px) {
|
|
690
|
+
.tab-bar-inner {
|
|
691
|
+
max-width: 560px;
|
|
692
|
+
margin: 0 auto;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
.tab-bar-glass {
|
|
696
|
+
/* Fallbacks first (in case color-mix isn't supported) */
|
|
697
|
+
background-color: rgba(255, 255, 255, 0.72);
|
|
698
|
+
background: rgba(255, 255, 255, 0.72);
|
|
699
|
+
/* Preferred (overrides when supported) */
|
|
700
|
+
background: color-mix(in srgb, var(--tabbar-bg, #ffffff) 76%, transparent);
|
|
701
|
+
backdrop-filter: blur(28px) saturate(180%);
|
|
702
|
+
-webkit-backdrop-filter: blur(28px) saturate(180%);
|
|
703
|
+
border: 1px solid rgba(255, 255, 255, 0.35);
|
|
704
|
+
border: 0.5px solid color-mix(in srgb, #ffffff 55%, transparent);
|
|
705
|
+
box-shadow:
|
|
706
|
+
0 8px 26px rgba(0, 0, 0, 0.18),
|
|
707
|
+
0 1px 0 rgba(255, 255, 255, 0.4) inset;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
.tab-bar-active-indicator {
|
|
711
|
+
background-color: rgba(255, 255, 255, 0.60);
|
|
712
|
+
background: color-mix(in srgb, #ffffff 62%, transparent);
|
|
713
|
+
box-shadow:
|
|
714
|
+
0 1px 0 rgba(255, 255, 255, 0.5) inset,
|
|
715
|
+
0 8px 20px rgba(0, 0, 0, 0.10);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
.tab-item:hover:not(.active) {
|
|
719
|
+
background: color-mix(in srgb, #ffffff 22%, transparent);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
620
723
|
/* ==================== DESKTOP TAB BAR (>= 641px) ==================== */
|
|
621
724
|
@media (min-width: 641px) {
|
|
622
725
|
.tab-stacks-container {
|
|
623
726
|
flex-direction: row;
|
|
624
727
|
}
|
|
625
728
|
|
|
626
|
-
.tab-stacks-container > .screen-stack
|
|
729
|
+
.tab-stacks-container > .screen-stack,
|
|
730
|
+
.tab-stacks-container > .split-view-container {
|
|
627
731
|
padding-bottom: 0;
|
|
628
732
|
margin-left: 260px;
|
|
629
733
|
min-width: 0;
|
|
@@ -660,10 +764,26 @@
|
|
|
660
764
|
border-radius: 0;
|
|
661
765
|
padding: 12px 0 0 0;
|
|
662
766
|
gap: 0;
|
|
663
|
-
background: #
|
|
767
|
+
background: var(--tabbar-bg, #ffffff);
|
|
664
768
|
box-shadow: none;
|
|
665
769
|
}
|
|
666
770
|
|
|
771
|
+
.tab-bar-glass {
|
|
772
|
+
display: none;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
.tab-bar-content {
|
|
776
|
+
flex-direction: column;
|
|
777
|
+
align-items: stretch;
|
|
778
|
+
justify-content: flex-start;
|
|
779
|
+
padding: 0;
|
|
780
|
+
gap: 0;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
.tab-bar-active-indicator {
|
|
784
|
+
display: none;
|
|
785
|
+
}
|
|
786
|
+
|
|
667
787
|
.tab-item {
|
|
668
788
|
margin: 0 12px;
|
|
669
789
|
margin-bottom: 4px;
|
|
@@ -2,7 +2,8 @@ import type { NavigationNode } from './navigationNode';
|
|
|
2
2
|
import type { HistoryItem, ScreenOptions, ActiveRoute, QueryPattern } from './types';
|
|
3
3
|
type Listener = () => void;
|
|
4
4
|
export interface RouterConfig {
|
|
5
|
-
|
|
5
|
+
roots: Record<string, NavigationNode>;
|
|
6
|
+
root: string;
|
|
6
7
|
screenOptions?: ScreenOptions;
|
|
7
8
|
debug?: boolean;
|
|
8
9
|
}
|
|
@@ -12,6 +13,8 @@ type RouterState = {
|
|
|
12
13
|
};
|
|
13
14
|
export declare class Router {
|
|
14
15
|
root: NavigationNode | null;
|
|
16
|
+
private readonly roots;
|
|
17
|
+
private activeRootKey;
|
|
15
18
|
private readonly listeners;
|
|
16
19
|
private readonly registry;
|
|
17
20
|
private state;
|
|
@@ -26,6 +29,7 @@ export declare class Router {
|
|
|
26
29
|
private activeRoute;
|
|
27
30
|
private rootListeners;
|
|
28
31
|
private rootTransition?;
|
|
32
|
+
private suppressRootTransitionOnNextRead;
|
|
29
33
|
private lastBrowserIndex;
|
|
30
34
|
private suppressHistorySyncCount;
|
|
31
35
|
private navigationToken;
|
|
@@ -53,7 +57,7 @@ export declare class Router {
|
|
|
53
57
|
subscribeRoot(listener: Listener): () => void;
|
|
54
58
|
private emitRootChange;
|
|
55
59
|
getRootTransition(): RootTransition | undefined;
|
|
56
|
-
setRoot(
|
|
60
|
+
setRoot(nextRootKey: string, options?: {
|
|
57
61
|
transition?: RootTransition;
|
|
58
62
|
}): void;
|
|
59
63
|
getActiveRoute: () => ActiveRoute;
|
|
@@ -17,6 +17,11 @@ export type InternalTabItem = Omit<TabItem, 'icon' | 'selectedIcon'> & {
|
|
|
17
17
|
icon?: ExtendedIcon;
|
|
18
18
|
selectedIcon?: ExtendedIcon;
|
|
19
19
|
badgeValue?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Optional base prefix for this tab's navigation subtree, e.g. '/mail'.
|
|
22
|
+
* Used by Router registry building via TabBar.getNodeChildren().
|
|
23
|
+
*/
|
|
24
|
+
tabPrefix?: string;
|
|
20
25
|
};
|
|
21
26
|
export type TabBarProps = {
|
|
22
27
|
onTabPress: (index: number) => void;
|
|
@@ -27,8 +32,25 @@ export type TabBarDescriptor = {
|
|
|
27
32
|
renderer?: ComponentType<TabBarProps>;
|
|
28
33
|
};
|
|
29
34
|
type TabBarConfig = Omit<InternalTabItem, 'tabKey' | 'key'> & {
|
|
35
|
+
/**
|
|
36
|
+
* Legacy content type: a stack rendered in the tab.
|
|
37
|
+
*/
|
|
30
38
|
stack?: NavigationStack;
|
|
39
|
+
/**
|
|
40
|
+
* New content type: any NavigationNode (e.g. SplitView).
|
|
41
|
+
*/
|
|
42
|
+
node?: NavigationNode;
|
|
43
|
+
/**
|
|
44
|
+
* Screen component rendered in the tab (no routing).
|
|
45
|
+
*/
|
|
31
46
|
screen?: React.ComponentType<any>;
|
|
47
|
+
/**
|
|
48
|
+
* Optional base prefix for node/stack routes, e.g. '/mail'.
|
|
49
|
+
*/
|
|
50
|
+
prefix?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Custom tab bar component (UI). Kept for compatibility.
|
|
53
|
+
*/
|
|
32
54
|
component?: ComponentType<TabBarProps>;
|
|
33
55
|
};
|
|
34
56
|
type TabBarOptions = {
|
|
@@ -40,6 +62,7 @@ export declare class TabBar implements NavigationNode {
|
|
|
40
62
|
private readonly tabBarId;
|
|
41
63
|
screens: Record<string, React.ComponentType<any>>;
|
|
42
64
|
stacks: Record<string, NavigationStack>;
|
|
65
|
+
nodes: Record<string, NavigationNode>;
|
|
43
66
|
private listeners;
|
|
44
67
|
private state;
|
|
45
68
|
constructor(options?: TabBarOptions);
|