@sigmela/router 0.2.0 → 0.2.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
@@ -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();
@@ -351,6 +373,27 @@ export class Router {
351
373
  }
352
374
  const matchResult = base.matchPath(pathname);
353
375
  const params = matchResult ? matchResult.params : undefined;
376
+
377
+ // Smart navigate:
378
+ // If navigate(push) targets the currently active routeId within the same stack, treat it as
379
+ // "same screen, new data" and perform a replace (preserving the existing key) by default.
380
+ //
381
+ // Consumers can opt out per-route via ScreenOptions.allowMultipleInstances=true.
382
+ if (action === 'push' && base.stackId) {
383
+ const mergedOptions = this.mergeOptions(base.options, base.stackId);
384
+ const allowMultipleInstances = mergedOptions?.allowMultipleInstances === true;
385
+ const isActiveSameStack = this.activeRoute?.stackId === base.stackId;
386
+ const isActiveSameRoute = this.activeRoute?.routeId === base.routeId;
387
+
388
+ // Optional safety: only apply to push-presentation screens by default.
389
+ const presentation = mergedOptions?.stackPresentation ?? 'push';
390
+ const isPushPresentation = presentation === 'push';
391
+ if (!allowMultipleInstances && isPushPresentation && isActiveSameStack && isActiveSameRoute) {
392
+ const newItem = this.createHistoryItem(base, params, query, pathname);
393
+ this.applyHistoryChange('replace', newItem);
394
+ return;
395
+ }
396
+ }
354
397
  if (action === 'push') {
355
398
  if (base.stackId) {
356
399
  let existing = this.findExistingRoute(base.stackId, base.routeId, pathname, params ?? {});
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { memo, useRef, useLayoutEffect, useMemo, useEffect, Children, isValidElement, Fragment } from 'react';
4
4
  import { useTransitionMap } from 'react-transition-state';
5
- import { ScreenStackItemsContext, ScreenStackAnimatingContext } from "./ScreenStackContext.js";
5
+ import { ScreenStackItemsContext, ScreenStackAnimatingContext, useScreenStackConfig } from "./ScreenStackContext.js";
6
6
  import { getPresentationTypeClass, computeAnimationType } from "./animationHelpers.js";
7
7
  import { jsx as _jsx } from "react/jsx-runtime";
8
8
  const devLog = (_, __) => {};
@@ -61,6 +61,8 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
61
61
  });
62
62
  const containerRef = useRef(null);
63
63
  const isInitialMountRef = useRef(true);
64
+ const suppressEnterAfterEmptyRef = useRef(false);
65
+ const suppressedEnterKeyRef = useRef(null);
64
66
  const prevKeysRef = useRef([]);
65
67
  const lastDirectionRef = useRef('forward');
66
68
  const childMapRef = useRef(new Map());
@@ -146,6 +148,8 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
146
148
  routeKeys,
147
149
  direction
148
150
  });
151
+ const screenStackConfig = useScreenStackConfig();
152
+ const animateFirstScreenAfterEmpty = screenStackConfig.animateFirstScreenAfterEmpty ?? true;
149
153
  const isInitialPhase = isInitialMountRef.current;
150
154
  const keysToRender = useMemo(() => {
151
155
  const routeKeySet = new Set(routeKeys);
@@ -179,10 +183,27 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
179
183
  }
180
184
  const newKeys = routeKeys.filter(key => !existingKeySet.has(key));
181
185
  const removedKeys = [...existingKeySet].filter(key => !routeKeySet.has(key));
186
+
187
+ // Track "became empty" state to suppress the next enter animation when configured.
188
+ // This is SplitView-secondary-only (via ScreenStackConfigContext), not global behavior.
189
+ if (!animateFirstScreenAfterEmpty) {
190
+ if (routeKeys.length === 0) {
191
+ suppressEnterAfterEmptyRef.current = true;
192
+ suppressedEnterKeyRef.current = null;
193
+ }
194
+ }
182
195
  devLog('[ScreenStack] Lifecycle diff', {
183
196
  newKeys,
184
197
  removedKeys
185
198
  });
199
+
200
+ // If this is the first pushed key after the stack was empty, remember its key so we can
201
+ // suppress only its enter animation (without affecting exit animations).
202
+ if (!animateFirstScreenAfterEmpty && suppressEnterAfterEmptyRef.current && routeKeys.length > 0 && newKeys.length > 0) {
203
+ const candidate = newKeys[newKeys.length - 1] ?? null;
204
+ suppressedEnterKeyRef.current = candidate;
205
+ suppressEnterAfterEmptyRef.current = false;
206
+ }
186
207
  for (const key of newKeys) {
187
208
  devLog(`[ScreenStack] Adding item: ${key}`);
188
209
  setItem(key);
@@ -206,7 +227,7 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
206
227
  }
207
228
  lastDirectionRef.current = direction;
208
229
  devLog('[ScreenStack] === LIFECYCLE EFFECT END ===');
209
- }, [routeKeys, direction, setItem, toggle, stateMapEntries, stateMap]);
230
+ }, [routeKeys, direction, setItem, toggle, stateMapEntries, stateMap, animateFirstScreenAfterEmpty]);
210
231
  useLayoutEffect(() => {
211
232
  devLog('[ScreenStack] === CLEANUP EFFECT START ===');
212
233
  const routeKeySet = new Set(routeKeys);
@@ -240,15 +261,31 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
240
261
  // If the stack mounts empty, we still want the first pushed screen to animate.
241
262
  // Mark initial mount as completed immediately in that case.
242
263
  if (!hasMountedItem && routeKeys.length === 0) {
243
- isInitialMountRef.current = false;
244
- devLog('[ScreenStack] Initial mount completed (empty stack)');
264
+ if (animateFirstScreenAfterEmpty) {
265
+ isInitialMountRef.current = false;
266
+ devLog('[ScreenStack] Initial mount completed (empty stack)');
267
+ }
245
268
  return;
246
269
  }
247
270
  if (hasMountedItem) {
248
271
  isInitialMountRef.current = false;
249
272
  devLog('[ScreenStack] Initial mount completed');
250
273
  }
251
- }, [stateMapEntries, routeKeys.length]);
274
+ }, [stateMapEntries, routeKeys.length, animateFirstScreenAfterEmpty]);
275
+
276
+ // Clear suppression key once it is no longer the top screen (so it can animate normally as
277
+ // a background when new screens are pushed).
278
+ useLayoutEffect(() => {
279
+ if (animateFirstScreenAfterEmpty) return;
280
+ const topKey = routeKeys[routeKeys.length - 1] ?? null;
281
+ if (!topKey) {
282
+ suppressedEnterKeyRef.current = null;
283
+ return;
284
+ }
285
+ if (suppressedEnterKeyRef.current && suppressedEnterKeyRef.current !== topKey) {
286
+ suppressedEnterKeyRef.current = null;
287
+ }
288
+ }, [routeKeys, animateFirstScreenAfterEmpty]);
252
289
  useEffect(() => {
253
290
  if (!containerRef.current) return;
254
291
  const items = containerRef.current.querySelectorAll('.screen-stack-item');
@@ -289,7 +326,12 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
289
326
  const routeIndex = routeKeys.indexOf(key);
290
327
  const zIndex = routeIndex >= 0 ? routeIndex + 1 : keysToRender.length + index + 1;
291
328
  const presentationType = getPresentationTypeClass(presentation);
292
- const animationType = computeAnimationType(key, isInStack, isTop, direction, presentation, isInitialPhase, animated);
329
+ let animationType = computeAnimationType(key, isInStack, isTop, direction, presentation, isInitialPhase, animated);
330
+
331
+ // SplitView-secondary-only: suppress enter animation for the first screen after empty.
332
+ if (!animateFirstScreenAfterEmpty && isTop && direction === 'forward' && suppressedEnterKeyRef.current === key) {
333
+ animationType = 'none';
334
+ }
293
335
  items[key] = {
294
336
  presentationType,
295
337
  animationType,
@@ -316,7 +358,10 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
316
358
  phase = 'inactive';
317
359
  }
318
360
  const presentationType = getPresentationTypeClass(presentation);
319
- const animationType = isInitialPhase ? 'none' : computeAnimationType(key, isInStack, isTop, direction, presentation, isInitialPhase, animated);
361
+ let animationType = isInitialPhase ? 'none' : computeAnimationType(key, isInStack, isTop, direction, presentation, isInitialPhase, animated);
362
+ if (!animateFirstScreenAfterEmpty && isTop && direction === 'forward' && suppressedEnterKeyRef.current === key) {
363
+ animationType = 'none';
364
+ }
320
365
  items[key] = {
321
366
  presentationType,
322
367
  animationType,
@@ -328,7 +373,7 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
328
373
  return {
329
374
  items
330
375
  };
331
- }, [keysToRender, stateMap, childMap, routeKeySet, topKey, isInitialPhase, routeKeys, direction]);
376
+ }, [keysToRender, stateMap, childMap, routeKeySet, topKey, isInitialPhase, routeKeys, direction, animateFirstScreenAfterEmpty]);
332
377
  const animating = useMemo(() => {
333
378
  return stateMapEntries.some(([, state]) => state.isMounted && (state.status === 'entering' || state.status === 'exiting' || state.status === 'preEnter' || state.status === 'preExit'));
334
379
  }, [stateMapEntries]);
@@ -3,6 +3,12 @@
3
3
  import { createContext, useContext } from 'react';
4
4
  export const ScreenStackItemsContext = /*#__PURE__*/createContext(null);
5
5
  export const ScreenStackAnimatingContext = /*#__PURE__*/createContext(false);
6
+ export const ScreenStackConfigContext = /*#__PURE__*/createContext({
7
+ animateFirstScreenAfterEmpty: true
8
+ });
9
+ export const useScreenStackConfig = () => {
10
+ return useContext(ScreenStackConfigContext);
11
+ };
6
12
  export const useScreenStackItemsContext = () => {
7
13
  const ctx = useContext(ScreenStackItemsContext);
8
14
  if (!ctx) {
@@ -3,7 +3,8 @@
3
3
  import { StackRenderer } from "../StackRenderer.js";
4
4
  import { SplitViewContext } from "./SplitViewContext.js";
5
5
  import { useRouter } from "../RouterContext.js";
6
- import { memo, useCallback, useMemo, useSyncExternalStore } from 'react';
6
+ import { ScreenStackConfigContext } from "../ScreenStack/ScreenStackContext.js";
7
+ import { memo, useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react';
7
8
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
9
  const StackSliceRenderer = /*#__PURE__*/memo(({
9
10
  stack,
@@ -46,7 +47,31 @@ export const RenderSplitView = /*#__PURE__*/memo(({
46
47
  splitView,
47
48
  appearance
48
49
  }) => {
49
- const instanceClass = useMemo(() => `split-view-instance-${splitView.getId()}`, [splitView]);
50
+ const [isWide, setIsWide] = useState(() => {
51
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
52
+ return false;
53
+ }
54
+ return window.matchMedia(`(min-width: ${splitView.minWidth}px)`).matches;
55
+ });
56
+ useEffect(() => {
57
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
58
+ return;
59
+ }
60
+ const mq = window.matchMedia(`(min-width: ${splitView.minWidth}px)`);
61
+ const onChange = ev => setIsWide(ev.matches);
62
+
63
+ // Sync immediately.
64
+ setIsWide(mq.matches);
65
+ if (typeof mq.addEventListener === 'function') {
66
+ mq.addEventListener('change', onChange);
67
+ return () => mq.removeEventListener('change', onChange);
68
+ }
69
+
70
+ // Safari < 14 fallback (deprecated, but still needed).
71
+ const mqAny = mq;
72
+ mqAny.addListener?.(onChange);
73
+ return () => mqAny.removeListener?.(onChange);
74
+ }, [splitView.minWidth]);
50
75
  const containerStyle = useMemo(() => {
51
76
  return {
52
77
  '--split-view-primary-max-width': `${splitView.primaryMaxWidth}px`
@@ -54,26 +79,31 @@ export const RenderSplitView = /*#__PURE__*/memo(({
54
79
  }, [splitView.primaryMaxWidth]);
55
80
  return /*#__PURE__*/_jsx(SplitViewContext.Provider, {
56
81
  value: splitView,
57
- children: /*#__PURE__*/_jsx("div", {
58
- className: instanceClass,
59
- children: /*#__PURE__*/_jsxs("div", {
60
- className: "split-view-container",
61
- style: containerStyle,
62
- children: [/*#__PURE__*/_jsx("div", {
63
- className: "split-view-primary",
64
- children: /*#__PURE__*/_jsx(StackSliceRenderer, {
65
- appearance: appearance,
66
- stack: splitView.primary,
67
- fallbackToFirstRoute: true
68
- })
69
- }), /*#__PURE__*/_jsx("div", {
70
- className: "split-view-secondary",
82
+ children: /*#__PURE__*/_jsxs("div", {
83
+ className: "split-view-container",
84
+ style: containerStyle,
85
+ children: [/*#__PURE__*/_jsx("div", {
86
+ className: "split-view-primary",
87
+ children: /*#__PURE__*/_jsx(StackSliceRenderer, {
88
+ appearance: appearance,
89
+ stack: splitView.primary,
90
+ fallbackToFirstRoute: true
91
+ })
92
+ }), /*#__PURE__*/_jsx("div", {
93
+ className: "split-view-secondary",
94
+ children: isWide ? /*#__PURE__*/_jsx(ScreenStackConfigContext.Provider, {
95
+ value: {
96
+ animateFirstScreenAfterEmpty: false
97
+ },
71
98
  children: /*#__PURE__*/_jsx(StackSliceRenderer, {
72
99
  appearance: appearance,
73
100
  stack: splitView.secondary
74
101
  })
75
- })]
76
- })
102
+ }) : /*#__PURE__*/_jsx(StackSliceRenderer, {
103
+ appearance: appearance,
104
+ stack: splitView.secondary
105
+ })
106
+ })]
77
107
  })
78
108
  });
79
109
  });
@@ -72,10 +72,17 @@ export const RenderTabBar = /*#__PURE__*/memo(({
72
72
  }, [router, tabBar, tabs, index]);
73
73
  const tabBarStyle = useMemo(() => {
74
74
  const tabBarBg = toColorString(appearance?.tabBar?.backgroundColor);
75
- return tabBarBg ? {
76
- backgroundColor: tabBarBg
77
- } : undefined;
78
- }, [appearance?.tabBar?.backgroundColor]);
75
+ const style = {
76
+ ...(tabBarBg ? {
77
+ ['--tabbar-bg']: tabBarBg
78
+ } : null),
79
+ ...(tabs.length ? {
80
+ ['--tabbar-tabs-count']: String(tabs.length),
81
+ ['--tabbar-active-index']: String(index)
82
+ } : null)
83
+ };
84
+ return Object.keys(style).length ? style : undefined;
85
+ }, [appearance?.tabBar?.backgroundColor, tabs.length, index]);
79
86
  const titleBaseStyle = useMemo(() => ({
80
87
  fontFamily: appearance?.tabBar?.title?.fontFamily,
81
88
  fontSize: appearance?.tabBar?.title?.fontSize,
@@ -97,37 +104,51 @@ export const RenderTabBar = /*#__PURE__*/memo(({
97
104
  }) : /*#__PURE__*/_jsx("div", {
98
105
  className: "tab-bar",
99
106
  style: tabBarStyle,
100
- children: /*#__PURE__*/_jsx("div", {
107
+ "data-tabs-count": tabs.length,
108
+ "data-active-index": index,
109
+ children: /*#__PURE__*/_jsxs("div", {
101
110
  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
- })
111
+ children: [/*#__PURE__*/_jsx("div", {
112
+ className: "tab-bar-glass",
113
+ "aria-hidden": "true"
114
+ }), /*#__PURE__*/_jsxs("div", {
115
+ className: "tab-bar-content",
116
+ children: [/*#__PURE__*/_jsx("div", {
117
+ className: "tab-bar-active-indicator",
118
+ "aria-hidden": "true"
119
+ }), tabs.map((tab, i) => {
120
+ const isActive = i === index;
121
+ const iconTint = toColorString(isActive ? appearance?.tabBar?.iconColorActive : appearance?.tabBar?.iconColor);
122
+ const title = appearance?.tabBar?.title;
123
+ const labelColor = isActive ? toColorString(title?.activeColor) ?? toColorString(title?.color) : toColorString(title?.color);
124
+ const labelStyle = {
125
+ ...titleBaseStyle,
126
+ color: labelColor
127
+ };
128
+ return /*#__PURE__*/_jsxs("button", {
129
+ type: "button",
130
+ "data-index": i,
131
+ "data-active": isActive ? 'true' : 'false',
132
+ "aria-current": isActive ? 'page' : undefined,
133
+ className: `tab-item${isActive ? ' active' : ''}`,
134
+ onClick: () => onTabClick(i),
135
+ children: [/*#__PURE__*/_jsx("div", {
136
+ className: "tab-item-icon",
137
+ children: isImageSource(tab.icon) ? /*#__PURE__*/_jsx(TabIcon, {
138
+ source: tab.icon,
139
+ tintColor: iconTint
140
+ }) : null
141
+ }), /*#__PURE__*/_jsx("div", {
142
+ className: "tab-item-label",
143
+ style: labelStyle,
144
+ children: tab.title
145
+ }), tab.badgeValue ? /*#__PURE__*/_jsx("span", {
146
+ className: "tab-item-label-badge",
147
+ children: tab.badgeValue
148
+ }) : null]
149
+ }, tab.tabKey);
150
+ })]
151
+ })]
131
152
  })
132
153
  })]
133
154
  })
@@ -82,7 +82,6 @@ export class TabBar {
82
82
  } catch (e) {
83
83
  // TabBar has no debug flag; keep behavior quiet in production.
84
84
  if (__DEV__) {
85
- // eslint-disable-next-line no-console
86
85
  console.error('[TabBar] listener error', e);
87
86
  }
88
87
  }
@@ -8,7 +8,7 @@ export const TAB_BAR_HEIGHT = 57;
8
8
  /**
9
9
  * Hook that returns the height of the TabBar
10
10
  * @returns {number} The height of the TabBar in pixels
11
- *
11
+ *
12
12
  * @example
13
13
  * ```tsx
14
14
  * const TabBarExample = () => {
@@ -493,6 +493,20 @@
493
493
  padding-bottom: calc(73px + env(safe-area-inset-bottom));
494
494
  }
495
495
 
496
+ .tab-bar {
497
+ /* CSS variables for theming / behavior */
498
+ --tabbar-bg: #ffffff;
499
+ --tabbar-gap: 4px;
500
+ --tabbar-radius: 9999px;
501
+ --tabbar-padding: 6px;
502
+ --tabbar-item-radius: 9999px;
503
+ --tabbar-height: 56px;
504
+
505
+ /* Used by active indicator (set inline in RenderTabBar.web.tsx) */
506
+ --tabbar-tabs-count: 1;
507
+ --tabbar-active-index: 0;
508
+ }
509
+
496
510
  .tab-bar-blur-overlay {
497
511
  position: fixed;
498
512
  bottom: 0;
@@ -516,23 +530,69 @@
516
530
  bottom: 0;
517
531
  left: 0;
518
532
  right: 0;
519
- padding: 8px 16px;
520
- padding-bottom: max(16px, env(safe-area-inset-bottom));
533
+ padding: 10px 12px;
534
+ padding-bottom: max(12px, env(safe-area-inset-bottom));
521
535
  background: transparent;
522
536
  z-index: 100;
523
537
  }
524
538
 
525
539
  .tab-bar-inner {
540
+ position: relative;
541
+ display: flex;
542
+ width: 100%;
543
+ max-width: 100%;
544
+ border-radius: var(--tabbar-radius);
545
+ overflow: hidden;
546
+ isolation: isolate;
547
+ width: 100%;
548
+ max-width: 100%;
549
+ }
550
+
551
+ .tab-bar-glass {
552
+ position: absolute;
553
+ inset: 0;
554
+ z-index: 0;
555
+ pointer-events: none;
556
+ }
557
+
558
+ .tab-bar-content {
559
+ position: relative;
560
+ z-index: 1;
526
561
  display: flex;
527
562
  flex-direction: row;
528
563
  align-items: center;
529
- background: #FFFFFF;
530
- box-shadow: 0 2px 8px #00000014;
531
- border-radius: 100px;
532
- padding: 4px;
533
- gap: 4px;
534
564
  width: 100%;
535
565
  max-width: 100%;
566
+ gap: var(--tabbar-gap);
567
+ padding: var(--tabbar-padding);
568
+ }
569
+
570
+ .tab-bar-active-indicator {
571
+ position: absolute;
572
+ top: var(--tabbar-padding);
573
+ bottom: var(--tabbar-padding);
574
+ left: var(--tabbar-padding);
575
+ border-radius: var(--tabbar-item-radius);
576
+ z-index: 0;
577
+ pointer-events: none;
578
+ opacity: 0;
579
+ transition:
580
+ transform 220ms cubic-bezier(0.22, 0.61, 0.36, 1),
581
+ opacity 180ms cubic-bezier(0.22, 0.61, 0.36, 1);
582
+ }
583
+
584
+ .tab-bar[data-tabs-count]:not([data-tabs-count="0"]) .tab-bar-active-indicator {
585
+ opacity: 1;
586
+ width: calc(
587
+ (
588
+ 100% -
589
+ (var(--tabbar-padding) * 2) -
590
+ (var(--tabbar-gap) * (var(--tabbar-tabs-count) - 1))
591
+ ) / var(--tabbar-tabs-count)
592
+ );
593
+ transform: translateX(
594
+ calc((var(--tabbar-active-index) * (100% + var(--tabbar-gap))))
595
+ );
536
596
  }
537
597
 
538
598
  .tab-item {
@@ -540,37 +600,44 @@
540
600
  background: transparent;
541
601
  border: 0;
542
602
  margin: 0;
543
- padding: 8px 16px;
544
- height: 49px;
603
+ padding: 6px 10px;
604
+ height: var(--tabbar-height);
545
605
  color: inherit;
546
606
  display: flex;
547
- flex: 1;
607
+ flex: 1 1 0;
548
608
  flex-direction: column;
549
609
  align-items: center;
550
610
  justify-content: center;
551
611
  gap: 4px;
552
612
  position: relative;
553
613
  cursor: pointer;
554
- border-radius: 100px;
555
- transition: all 200ms cubic-bezier(0.22, 0.61, 0.36, 1);
614
+ border-radius: var(--tabbar-item-radius);
615
+ transition:
616
+ transform 120ms cubic-bezier(0.22, 0.61, 0.36, 1),
617
+ background-color 200ms cubic-bezier(0.22, 0.61, 0.36, 1),
618
+ color 200ms cubic-bezier(0.22, 0.61, 0.36, 1);
556
619
  }
557
620
 
558
621
  .tab-item.active {
559
- background: #EDEDED;
622
+ background: transparent;
560
623
  }
561
624
 
562
625
  .tab-item:hover:not(.active) {
563
- background: color-mix(in srgb, currentColor 8%, transparent);
626
+ background: color-mix(in srgb, #0a0a0a 6%, transparent);
564
627
  }
565
628
 
629
+ .tab-item:active {
630
+ transform: scale(0.98);
631
+ }
566
632
 
567
633
  .tab-item:focus {
568
634
  outline: none;
569
635
  }
570
636
 
571
637
  .tab-item:focus-visible {
572
- outline: 2px solid currentColor;
573
- outline-offset: 2px;
638
+ box-shadow:
639
+ 0 0 0 2px color-mix(in srgb, currentColor 40%, transparent),
640
+ 0 10px 28px rgba(0, 0, 0, 0.12);
574
641
  }
575
642
 
576
643
  .tab-item-icon {
@@ -617,6 +684,41 @@
617
684
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
618
685
  }
619
686
 
687
+ /* ==================== MOBILE TAB BAR (<= 640px) — iOS-like floating glass pill ==================== */
688
+ @media (max-width: 640px) {
689
+ .tab-bar-inner {
690
+ max-width: 560px;
691
+ margin: 0 auto;
692
+ }
693
+
694
+ .tab-bar-glass {
695
+ /* Fallbacks first (in case color-mix isn't supported) */
696
+ background-color: rgba(255, 255, 255, 0.72);
697
+ background: rgba(255, 255, 255, 0.72);
698
+ /* Preferred (overrides when supported) */
699
+ background: color-mix(in srgb, var(--tabbar-bg, #ffffff) 76%, transparent);
700
+ backdrop-filter: blur(28px) saturate(180%);
701
+ -webkit-backdrop-filter: blur(28px) saturate(180%);
702
+ border: 1px solid rgba(255, 255, 255, 0.35);
703
+ border: 0.5px solid color-mix(in srgb, #ffffff 55%, transparent);
704
+ box-shadow:
705
+ 0 8px 26px rgba(0, 0, 0, 0.18),
706
+ 0 1px 0 rgba(255, 255, 255, 0.4) inset;
707
+ }
708
+
709
+ .tab-bar-active-indicator {
710
+ background-color: rgba(255, 255, 255, 0.60);
711
+ background: color-mix(in srgb, #ffffff 62%, transparent);
712
+ box-shadow:
713
+ 0 1px 0 rgba(255, 255, 255, 0.5) inset,
714
+ 0 8px 20px rgba(0, 0, 0, 0.10);
715
+ }
716
+
717
+ .tab-item:hover:not(.active) {
718
+ background: color-mix(in srgb, #ffffff 22%, transparent);
719
+ }
720
+ }
721
+
620
722
  /* ==================== DESKTOP TAB BAR (>= 641px) ==================== */
621
723
  @media (min-width: 641px) {
622
724
  .tab-stacks-container {
@@ -660,10 +762,26 @@
660
762
  border-radius: 0;
661
763
  padding: 12px 0 0 0;
662
764
  gap: 0;
663
- background: #FFFFFF;
765
+ background: var(--tabbar-bg, #ffffff);
664
766
  box-shadow: none;
665
767
  }
666
768
 
769
+ .tab-bar-glass {
770
+ display: none;
771
+ }
772
+
773
+ .tab-bar-content {
774
+ flex-direction: column;
775
+ align-items: stretch;
776
+ justify-content: flex-start;
777
+ padding: 0;
778
+ gap: 0;
779
+ }
780
+
781
+ .tab-bar-active-indicator {
782
+ display: none;
783
+ }
784
+
667
785
  .tab-item {
668
786
  margin: 0 12px;
669
787
  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,15 @@ export type ScreenStackItemsContextValue = {
17
17
  export type ScreenStackAnimatingContextValue = boolean;
18
18
  export declare const ScreenStackItemsContext: import("react").Context<ScreenStackItemsContextValue | null>;
19
19
  export declare const ScreenStackAnimatingContext: import("react").Context<boolean>;
20
+ export type ScreenStackConfig = {
21
+ /**
22
+ * When false, the first screen pushed after the stack becomes empty will NOT animate
23
+ * (treated as an initial render). Subsequent pushes/pops still animate normally.
24
+ */
25
+ animateFirstScreenAfterEmpty?: boolean;
26
+ };
27
+ export declare const ScreenStackConfigContext: import("react").Context<ScreenStackConfig>;
28
+ export declare const useScreenStackConfig: () => ScreenStackConfig;
20
29
  export declare const useScreenStackItemsContext: () => ScreenStackItemsContextValue;
21
30
  export declare const useScreenStackAnimatingContext: () => boolean;
22
31
  //# sourceMappingURL=ScreenStackContext.d.ts.map
@@ -16,6 +16,14 @@ export type ScreenOptions = Partial<Omit<RNSScreenProps, 'stackPresentation'>> &
16
16
  syncWithUrl?: boolean;
17
17
  tabBarIcon?: TabBarIcon;
18
18
  animated?: boolean;
19
+ /**
20
+ * Allows pushing multiple instances of the same screen (same routeId) onto a stack.
21
+ *
22
+ * By default, Router.navigate() will behave like "replace" when targeting the currently
23
+ * active routeId in the same stack (i.e. treat it as "same screen, new data").
24
+ * Set this to true to force "push" even when navigating to the active routeId.
25
+ */
26
+ allowMultipleInstances?: boolean;
19
27
  /**
20
28
  * Allows Router.goBack() to pop the last (root) screen of a stack.
21
29
  * Useful for secondary stacks in split-view / overlays.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigmela/router",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "React Native Router",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",