@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 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({ root: rootStack });
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({ root: tabBar });
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(nextRoot, { transition? })` — swap root at runtime
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[] }`
@@ -47,7 +47,7 @@ export const Navigation = /*#__PURE__*/memo(({
47
47
  stackId: globalId,
48
48
  item: item
49
49
  }, `global-${item.key}`))]
50
- })
50
+ }, rootId ?? 'root')
51
51
  });
52
52
  });
53
53
  const styles = StyleSheet.create({
@@ -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.root = config.root;
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(nextRoot, options) {
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
- return tabBarBg ? {
76
- backgroundColor: tabBarBg
77
- } : undefined;
78
- }, [appearance?.tabBar?.backgroundColor]);
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
- children: /*#__PURE__*/_jsx("div", {
146
+ "data-tabs-count": tabs.length,
147
+ "data-active-index": index,
148
+ children: /*#__PURE__*/_jsxs("div", {
101
149
  className: "tab-bar-inner",
102
- children: tabs.map((tab, i) => {
103
- const isActive = i === index;
104
- const iconTint = toColorString(isActive ? appearance?.tabBar?.iconColorActive : appearance?.tabBar?.iconColor);
105
- const title = appearance?.tabBar?.title;
106
- const labelColor = isActive ? toColorString(title?.activeColor) ?? toColorString(title?.color) : toColorString(title?.color);
107
- const labelStyle = {
108
- ...titleBaseStyle,
109
- color: labelColor
110
- };
111
- return /*#__PURE__*/_jsxs("button", {
112
- "data-index": i,
113
- className: `tab-item${isActive ? ' active' : ''}`,
114
- onClick: () => onTabClick(i),
115
- children: [/*#__PURE__*/_jsx("div", {
116
- className: "tab-item-icon",
117
- children: isImageSource(tab.icon) ? /*#__PURE__*/_jsx(TabIcon, {
118
- source: tab.icon,
119
- tintColor: iconTint
120
- }) : null
121
- }), /*#__PURE__*/_jsx("div", {
122
- className: "tab-item-label",
123
- style: labelStyle,
124
- children: tab.title
125
- }), tab.badgeValue ? /*#__PURE__*/_jsx("span", {
126
- className: "tab-item-label-badge",
127
- children: tab.badgeValue
128
- }) : null]
129
- }, tab.tabKey);
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 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
  }
@@ -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: 8px 16px;
520
- padding-bottom: max(16px, env(safe-area-inset-bottom));
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: 8px 16px;
544
- height: 49px;
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: 100px;
555
- transition: all 200ms cubic-bezier(0.22, 0.61, 0.36, 1);
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: #EDEDED;
623
+ background: transparent;
560
624
  }
561
625
 
562
626
  .tab-item:hover:not(.active) {
563
- background: color-mix(in srgb, currentColor 8%, transparent);
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
- outline: 2px solid currentColor;
573
- outline-offset: 2px;
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: #FFFFFF;
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
- root: NavigationNode;
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(nextRoot: NavigationNode, options?: {
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigmela/router",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "React Native Router",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",