@sigmela/router 0.2.0 → 0.2.1
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/lib/module/Router.js +21 -0
- 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/TabBar.js +0 -1
- package/lib/module/TabBar/useTabBarHeight.js +1 -1
- 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/lib/module/Router.js
CHANGED
|
@@ -351,6 +351,27 @@ export class Router {
|
|
|
351
351
|
}
|
|
352
352
|
const matchResult = base.matchPath(pathname);
|
|
353
353
|
const params = matchResult ? matchResult.params : undefined;
|
|
354
|
+
|
|
355
|
+
// Smart navigate:
|
|
356
|
+
// If navigate(push) targets the currently active routeId within the same stack, treat it as
|
|
357
|
+
// "same screen, new data" and perform a replace (preserving the existing key) by default.
|
|
358
|
+
//
|
|
359
|
+
// Consumers can opt out per-route via ScreenOptions.allowMultipleInstances=true.
|
|
360
|
+
if (action === 'push' && base.stackId) {
|
|
361
|
+
const mergedOptions = this.mergeOptions(base.options, base.stackId);
|
|
362
|
+
const allowMultipleInstances = mergedOptions?.allowMultipleInstances === true;
|
|
363
|
+
const isActiveSameStack = this.activeRoute?.stackId === base.stackId;
|
|
364
|
+
const isActiveSameRoute = this.activeRoute?.routeId === base.routeId;
|
|
365
|
+
|
|
366
|
+
// Optional safety: only apply to push-presentation screens by default.
|
|
367
|
+
const presentation = mergedOptions?.stackPresentation ?? 'push';
|
|
368
|
+
const isPushPresentation = presentation === 'push';
|
|
369
|
+
if (!allowMultipleInstances && isPushPresentation && isActiveSameStack && isActiveSameRoute) {
|
|
370
|
+
const newItem = this.createHistoryItem(base, params, query, pathname);
|
|
371
|
+
this.applyHistoryChange('replace', newItem);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
354
375
|
if (action === 'push') {
|
|
355
376
|
if (base.stackId) {
|
|
356
377
|
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
|
});
|
|
@@ -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.
|