@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 +5 -4
- package/lib/module/Navigation.js +1 -1
- package/lib/module/Router.js +45 -2
- package/lib/module/ScreenStack/ScreenStack.web.js +53 -8
- package/lib/module/ScreenStack/ScreenStackContext.js +6 -0
- package/lib/module/SplitView/RenderSplitView.web.js +48 -18
- package/lib/module/TabBar/RenderTabBar.web.js +55 -34
- package/lib/module/TabBar/TabBar.js +0 -1
- package/lib/module/TabBar/useTabBarHeight.js +1 -1
- package/lib/module/styles.css +135 -17
- package/lib/typescript/src/Router.d.ts +6 -2
- package/lib/typescript/src/ScreenStack/ScreenStackContext.d.ts +9 -0
- package/lib/typescript/src/types.d.ts +8 -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();
|
|
@@ -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
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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__*/
|
|
58
|
-
className:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
children:
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
107
|
+
"data-tabs-count": tabs.length,
|
|
108
|
+
"data-active-index": index,
|
|
109
|
+
children: /*#__PURE__*/_jsxs("div", {
|
|
101
110
|
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
|
-
|
|
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
|
})
|
package/lib/module/styles.css
CHANGED
|
@@ -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:
|
|
520
|
-
padding-bottom: max(
|
|
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:
|
|
544
|
-
height:
|
|
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:
|
|
555
|
-
transition:
|
|
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:
|
|
622
|
+
background: transparent;
|
|
560
623
|
}
|
|
561
624
|
|
|
562
625
|
.tab-item:hover:not(.active) {
|
|
563
|
-
background: color-mix(in srgb,
|
|
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
|
-
|
|
573
|
-
|
|
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: #
|
|
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
|
-
|
|
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,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.
|