@sigmela/router 0.2.8 → 0.3.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.
Files changed (39) hide show
  1. package/README.md +107 -1
  2. package/lib/module/Drawer/Drawer.js +250 -0
  3. package/lib/module/Drawer/DrawerContext.js +4 -0
  4. package/lib/module/Drawer/DrawerIcon.web.js +47 -0
  5. package/lib/module/Drawer/RenderDrawer.native.js +244 -0
  6. package/lib/module/Drawer/RenderDrawer.web.js +197 -0
  7. package/lib/module/Drawer/useDrawer.js +11 -0
  8. package/lib/module/Navigation.js +4 -2
  9. package/lib/module/NavigationStack.js +14 -4
  10. package/lib/module/Router.js +214 -60
  11. package/lib/module/RouterContext.js +1 -1
  12. package/lib/module/ScreenStack/ScreenStack.web.js +78 -12
  13. package/lib/module/ScreenStackItem/ScreenStackItem.js +7 -7
  14. package/lib/module/ScreenStackItem/ScreenStackItem.web.js +65 -6
  15. package/lib/module/ScreenStackSheetItem/ScreenStackSheetItem.native.js +4 -5
  16. package/lib/module/SplitView/RenderSplitView.web.js +5 -7
  17. package/lib/module/SplitView/SplitView.js +10 -2
  18. package/lib/module/StackRenderer.js +10 -4
  19. package/lib/module/TabBar/RenderTabBar.native.js +55 -24
  20. package/lib/module/TabBar/RenderTabBar.web.js +25 -2
  21. package/lib/module/TabBar/TabBar.js +8 -1
  22. package/lib/module/TabBar/TabIcon.web.js +12 -7
  23. package/lib/module/index.js +2 -0
  24. package/lib/module/styles.css +255 -91
  25. package/lib/typescript/src/Drawer/Drawer.d.ts +100 -0
  26. package/lib/typescript/src/Drawer/DrawerContext.d.ts +3 -0
  27. package/lib/typescript/src/Drawer/DrawerIcon.web.d.ts +7 -0
  28. package/lib/typescript/src/Drawer/RenderDrawer.native.d.ts +8 -0
  29. package/lib/typescript/src/Drawer/RenderDrawer.web.d.ts +8 -0
  30. package/lib/typescript/src/Drawer/useDrawer.d.ts +2 -0
  31. package/lib/typescript/src/NavigationStack.d.ts +1 -0
  32. package/lib/typescript/src/Router.d.ts +13 -0
  33. package/lib/typescript/src/ScreenStack/ScreenStackContext.d.ts +1 -1
  34. package/lib/typescript/src/ScreenStack/animationHelpers.d.ts +1 -1
  35. package/lib/typescript/src/SplitView/SplitView.d.ts +2 -0
  36. package/lib/typescript/src/TabBar/TabBar.d.ts +10 -1
  37. package/lib/typescript/src/index.d.ts +5 -0
  38. package/lib/typescript/src/types.d.ts +12 -3
  39. package/package.json +28 -12
@@ -6,7 +6,7 @@ import { ScreenStackItemsContext, ScreenStackAnimatingContext, useScreenStackCon
6
6
  import { getPresentationTypeClass, computeAnimationType } from "./animationHelpers.js";
7
7
  import { RouterContext } from "../RouterContext.js";
8
8
  import { isModalLikePresentation } from "../types.js";
9
- import { jsx as _jsx } from "react/jsx-runtime";
9
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
10
10
  const isScreenStackItemElement = child => {
11
11
  if (! /*#__PURE__*/isValidElement(child)) return false;
12
12
  const anyProps = child.props;
@@ -58,8 +58,9 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
58
58
  const router = useContext(RouterContext);
59
59
  const debugEnabled = router?.isDebugEnabled() ?? false;
60
60
  const devLog = useCallback((msg, data) => {
61
- if (!debugEnabled) return;
62
- console.log(msg, data !== undefined ? JSON.stringify(data) : '');
61
+ if (typeof __DEV__ !== 'undefined' && __DEV__ && debugEnabled) {
62
+ console.log(msg, data !== undefined ? JSON.stringify(data) : '');
63
+ }
63
64
  }, [debugEnabled]);
64
65
  devLog('[ScreenStack] Render', {
65
66
  transitionTime,
@@ -121,13 +122,15 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
121
122
  const key = item?.key || getItemKey(child);
122
123
  map.set(key, child);
123
124
  }
124
- childMapRef.current = map;
125
125
  devLog('[ScreenStack] childMap updated', {
126
126
  size: map.size,
127
127
  keys: Array.from(map.keys())
128
128
  });
129
129
  return map;
130
130
  }, [devLog, stackChildren]);
131
+ useEffect(() => {
132
+ childMapRef.current = childMap;
133
+ }, [childMap]);
131
134
  const {
132
135
  stateMap,
133
136
  toggle,
@@ -160,7 +163,7 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
160
163
  isEnter: state.isEnter,
161
164
  isResolved: state.isResolved
162
165
  })));
163
- const stateMapEntries = Array.from(stateMap.entries());
166
+ const stateMapEntries = useMemo(() => Array.from(stateMap.entries()), [stateMap]);
164
167
  const prevKeysForDirection = prevKeysRef.current;
165
168
  const direction = useMemo(() => {
166
169
  const computed = computeDirection(prevKeysForDirection, routeKeys);
@@ -190,9 +193,7 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
190
193
  });
191
194
  return result;
192
195
  }, [devLog, routeKeys, stateMapEntries]);
193
- const containerClassName = useMemo(() => {
194
- return 'screen-stack';
195
- }, []);
196
+ const containerClassName = 'screen-stack';
196
197
 
197
198
  // CRITICAL: Calculate bulk removal BEFORE useMemo for itemsContextValue
198
199
  // so the flag is available when computing animation types
@@ -331,6 +332,7 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
331
332
  }
332
333
  }, [routeKeys, animateFirstScreenAfterEmpty]);
333
334
  useEffect(() => {
335
+ if (!debugEnabled) return;
334
336
  if (!containerRef.current) return;
335
337
  const items = containerRef.current.querySelectorAll('.screen-stack-item');
336
338
  if (items.length === 0) return;
@@ -340,7 +342,7 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
340
342
  containerDataDirection: containerRef.current.dataset.direction,
341
343
  itemCount: items.length
342
344
  });
343
- });
345
+ }, [debugEnabled, devLog]);
344
346
  const topKey = routeKeys[routeKeys.length - 1] ?? null;
345
347
  const routeKeySet = useMemo(() => new Set(routeKeys), [routeKeys]);
346
348
  const hasExitingItems = useMemo(() => {
@@ -353,6 +355,47 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
353
355
  return isModalLikePresentation(item?.options?.stackPresentation);
354
356
  });
355
357
  }, [childMap, routeKeySet, stateMapEntries]);
358
+
359
+ // D4: Compute active modal/sheet state for data attributes
360
+ const hasActiveModal = useMemo(() => {
361
+ for (const key of routeKeys) {
362
+ const child = childMap.get(key);
363
+ if (!child) continue;
364
+ const presentation = child.props.item?.options?.stackPresentation;
365
+ if (presentation && presentation !== 'push' && presentation !== 'formSheet' && presentation !== 'pageSheet' && presentation !== 'sheet' && isModalLikePresentation(presentation)) {
366
+ return true;
367
+ }
368
+ }
369
+ return false;
370
+ }, [routeKeys, childMap]);
371
+ const hasActiveSheet = useMemo(() => {
372
+ for (const key of routeKeys) {
373
+ const child = childMap.get(key);
374
+ if (!child) continue;
375
+ const presentation = child.props.item?.options?.stackPresentation;
376
+ if (presentation === 'formSheet' || presentation === 'pageSheet' || presentation === 'sheet') {
377
+ return true;
378
+ }
379
+ }
380
+ return false;
381
+ }, [routeKeys, childMap]);
382
+
383
+ // D5: Scroll lock during active modal (ref-counted for nested stacks)
384
+ useEffect(() => {
385
+ if (!hasActiveModal) return;
386
+ const g = globalThis;
387
+ g.__scrollLockCount = (g.__scrollLockCount ?? 0) + 1;
388
+ if (g.__scrollLockCount === 1) {
389
+ g.__scrollLockPrev = document.body.style.overflow;
390
+ document.body.style.overflow = 'hidden';
391
+ }
392
+ return () => {
393
+ g.__scrollLockCount = Math.max(0, (g.__scrollLockCount ?? 1) - 1);
394
+ if (g.__scrollLockCount === 0) {
395
+ document.body.style.overflow = g.__scrollLockPrev ?? '';
396
+ }
397
+ };
398
+ }, [hasActiveModal]);
356
399
  const animationDirection = hasExitingItems ? lastDirectionRef.current : direction;
357
400
  const stackDismissedByRouter = router?.isStackBeingDismissed?.(currentStackId ?? undefined) ?? false;
358
401
  if (stackDismissedByRouter) {
@@ -459,17 +502,40 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
459
502
  const animating = useMemo(() => {
460
503
  return stateMapEntries.some(([, state]) => state.isMounted && (state.status === 'entering' || state.status === 'exiting' || state.status === 'preEnter' || state.status === 'preExit'));
461
504
  }, [stateMapEntries]);
505
+
506
+ // D6: Compute current screen label for accessibility
507
+ const currentScreenLabel = useMemo(() => {
508
+ if (!topKey) return '';
509
+ const child = childMap.get(topKey);
510
+ if (!child) return '';
511
+ const item = child.props.item;
512
+ return item?.routeId || topKey;
513
+ }, [topKey, childMap]);
462
514
  return /*#__PURE__*/_jsx(ScreenStackItemsContext.Provider, {
463
515
  value: itemsContextValue,
464
516
  children: /*#__PURE__*/_jsx(ScreenStackAnimatingContext.Provider, {
465
517
  value: animating,
466
- children: /*#__PURE__*/_jsx("div", {
518
+ children: /*#__PURE__*/_jsxs("div", {
467
519
  ref: containerRef,
468
520
  className: containerClassName + (animating ? ' animating' : ''),
521
+ "data-has-active-modal": hasActiveModal ? 'true' : undefined,
522
+ "data-has-active-sheet": hasActiveSheet ? 'true' : undefined,
523
+ "data-animation": animating ? 'true' : undefined,
469
524
  style: {
470
525
  '--stack-transition-time': `${transitionTime}ms`
471
526
  },
472
- children: keysToRender.map(key => {
527
+ children: [/*#__PURE__*/_jsx("div", {
528
+ "aria-live": "polite",
529
+ role: "status",
530
+ style: {
531
+ position: 'absolute',
532
+ width: 1,
533
+ height: 1,
534
+ overflow: 'hidden',
535
+ clip: 'rect(0,0,0,0)'
536
+ },
537
+ children: currentScreenLabel
538
+ }), keysToRender.map(key => {
473
539
  const transitionState = stateMap.get(key);
474
540
  if (!transitionState || !transitionState.isMounted) {
475
541
  devLog(`[ScreenStack] Skipping ${key} - no state or not mounted`, {
@@ -488,7 +554,7 @@ export const ScreenStack = /*#__PURE__*/memo(props => {
488
554
  return /*#__PURE__*/_jsx(Fragment, {
489
555
  children: child
490
556
  }, child.key || key);
491
- })
557
+ })]
492
558
  })
493
559
  })
494
560
  });
@@ -4,7 +4,7 @@ import { ScreenStackItem as RNSScreenStackItem } from 'react-native-screens';
4
4
  import { RouteLocalContext, useRouter } from "../RouterContext.js";
5
5
  import { ScreenStackSheetItem } from "../ScreenStackSheetItem/index.js";
6
6
  import { StyleSheet } from 'react-native';
7
- import { memo } from 'react';
7
+ import { memo, useMemo, useCallback } from 'react';
8
8
  import { jsx as _jsx } from "react/jsx-runtime";
9
9
  export const ScreenStackItem = /*#__PURE__*/memo(({
10
10
  item,
@@ -20,15 +20,15 @@ export const ScreenStackItem = /*#__PURE__*/memo(({
20
20
 
21
21
  // On native, modalRight behaves as regular modal
22
22
  const nativePresentation = stackPresentation === 'modalRight' ? 'modal' : stackPresentation;
23
- const route = {
23
+ const route = useMemo(() => ({
24
24
  presentation: stackPresentation ?? 'push',
25
25
  params: item.params,
26
26
  query: item.query,
27
27
  pattern: item.pattern,
28
28
  path: item.path
29
- };
29
+ }), [stackPresentation, item.params, item.query, item.pattern, item.path]);
30
30
  const router = useRouter();
31
- const onDismissed = () => {
31
+ const onDismissed = useCallback(() => {
32
32
  if (stackId) {
33
33
  const history = router.getStackHistory(stackId);
34
34
  const topKey = history.length ? history[history.length - 1]?.key : null;
@@ -36,12 +36,12 @@ export const ScreenStackItem = /*#__PURE__*/memo(({
36
36
  router.goBack();
37
37
  }
38
38
  }
39
- };
40
- const headerConfig = {
39
+ }, [stackId, router, item.key]);
40
+ const headerConfig = useMemo(() => ({
41
41
  ...header,
42
42
  hidden: !header?.title || header?.hidden,
43
43
  backgroundColor: appearance?.header?.backgroundColor ?? 'transparent'
44
- };
44
+ }), [header, appearance?.header?.backgroundColor]);
45
45
  if (route.presentation === 'sheet') {
46
46
  return /*#__PURE__*/_jsx(ScreenStackSheetItem, {
47
47
  appearance: appearance?.sheet,
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { RouteLocalContext, useRouter } from "../RouterContext.js";
4
4
  import { isModalLikePresentation } from "../types.js";
5
- import { memo, useMemo, useCallback } from 'react';
5
+ import { memo, useMemo, useCallback, useEffect, useRef } from 'react';
6
6
  import { StyleSheet, View } from 'react-native';
7
7
  import { useScreenStackItemsContext } from "../ScreenStack/ScreenStackContext.js";
8
8
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
@@ -13,6 +13,7 @@ export const ScreenStackItem = /*#__PURE__*/memo(({
13
13
  }) => {
14
14
  const itemsContext = useScreenStackItemsContext();
15
15
  const router = useRouter();
16
+ const containerRef = useRef(null);
16
17
  const debugEnabled = router.isDebugEnabled();
17
18
  const devLog = useCallback((msg, data) => {
18
19
  if (!debugEnabled) return;
@@ -65,36 +66,94 @@ export const ScreenStackItem = /*#__PURE__*/memo(({
65
66
  maxWidth: `${item.options.maxWidth}px`
66
67
  };
67
68
  }, [isModalLike, item.options?.maxWidth]);
68
- const value = {
69
+ const routeValue = useMemo(() => ({
69
70
  presentation,
70
71
  params: item.params,
71
72
  query: item.query,
72
73
  pattern: item.pattern,
73
74
  path: item.path
74
- };
75
+ }), [presentation, item.params, item.query, item.pattern, item.path]);
76
+ const handleDismiss = useCallback(() => router.dismiss(), [router]);
77
+ const handleDismissKeyDown = useCallback(e => {
78
+ if (e.key === 'Enter' || e.key === ' ') {
79
+ e.preventDefault?.();
80
+ router.dismiss();
81
+ }
82
+ }, [router]);
83
+
84
+ // Escape key handler for modals — only dismiss if this is the topmost modal
85
+ useEffect(() => {
86
+ if (!isModalLike) return;
87
+ const handler = e => {
88
+ if (e.key !== 'Escape') return;
89
+ const allModals = document.querySelectorAll('[aria-modal="true"]');
90
+ if (allModals.length > 0) {
91
+ const last = allModals[allModals.length - 1] ?? null;
92
+ if (!containerRef.current?.contains(last)) return;
93
+ }
94
+ router.dismiss();
95
+ };
96
+ document.addEventListener('keydown', handler);
97
+ return () => document.removeEventListener('keydown', handler);
98
+ }, [isModalLike, router]);
99
+
100
+ // Focus trap for modals — re-queries focusable elements on each Tab
101
+ useEffect(() => {
102
+ if (!isModalLike || !containerRef.current) return;
103
+ const container = containerRef.current;
104
+ const FOCUSABLE = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
105
+ const focusable = container.querySelectorAll(FOCUSABLE);
106
+ focusable[0]?.focus();
107
+ const trap = e => {
108
+ if (e.key !== 'Tab') return;
109
+ const els = container.querySelectorAll(FOCUSABLE);
110
+ if (!els.length) return;
111
+ const first = els[0];
112
+ const last = els[els.length - 1];
113
+ if (e.shiftKey && document.activeElement === first) {
114
+ e.preventDefault();
115
+ last?.focus();
116
+ } else if (!e.shiftKey && document.activeElement === last) {
117
+ e.preventDefault();
118
+ first?.focus();
119
+ }
120
+ };
121
+ container.addEventListener('keydown', trap);
122
+ return () => container.removeEventListener('keydown', trap);
123
+ }, [isModalLike]);
75
124
  if (!itemState) {
76
125
  return null;
77
126
  }
78
127
  return /*#__PURE__*/_jsxs("div", {
128
+ ref: containerRef,
79
129
  style: mergedStyle,
80
130
  className: className,
81
131
  children: [isModalLike && /*#__PURE__*/_jsx("div", {
82
132
  className: "stack-modal-overlay",
83
- onClick: () => router.dismiss()
133
+ onClick: handleDismiss,
134
+ onKeyDown: handleDismissKeyDown,
135
+ role: "button",
136
+ "aria-label": "Close modal",
137
+ tabIndex: 0
84
138
  }), /*#__PURE__*/_jsx("div", {
85
139
  className: isModalLike ? 'stack-modal-container' : 'stack-screen-container',
86
140
  style: modalContainerStyle,
141
+ ...(isModalLike ? {
142
+ 'role': 'dialog',
143
+ 'aria-modal': true,
144
+ 'aria-label': item.pattern || 'Dialog'
145
+ } : undefined),
87
146
  children: appearance?.screen ? /*#__PURE__*/_jsx(View, {
88
147
  style: [appearance?.screen, styles.flex],
89
148
  children: /*#__PURE__*/_jsx(RouteLocalContext.Provider, {
90
- value: value,
149
+ value: routeValue,
91
150
  children: /*#__PURE__*/_jsx(item.component, {
92
151
  ...(item.passProps || {}),
93
152
  appearance: appearance
94
153
  })
95
154
  })
96
155
  }) : /*#__PURE__*/_jsx(RouteLocalContext.Provider, {
97
- value: value,
156
+ value: routeValue,
98
157
  children: /*#__PURE__*/_jsx(item.component, {
99
158
  ...(item.passProps || {}),
100
159
  appearance: appearance
@@ -3,7 +3,7 @@
3
3
  import { ScreenStackItem as RNSScreenStackItem } from 'react-native-screens';
4
4
  import { NativeSheetView, Commands } from '@sigmela/native-sheet';
5
5
  import { RouteLocalContext, useRouter } from "../RouterContext.js";
6
- import { memo, useRef, useEffect } from 'react';
6
+ import { memo, useRef, useEffect, useCallback } from 'react';
7
7
  import { StyleSheet } from 'react-native';
8
8
  import { jsx as _jsx } from "react/jsx-runtime";
9
9
  export const ScreenStackSheetItem = /*#__PURE__*/memo(props => {
@@ -31,12 +31,11 @@ export const ScreenStackSheetItem = /*#__PURE__*/memo(props => {
31
31
  return () => router.unregisterSheetDismisser(item.key);
32
32
  }
33
33
  return undefined;
34
- // eslint-disable-next-line react-hooks/exhaustive-deps
35
- }, []);
36
- const handleSheetDismissed = () => {
34
+ }, [route.presentation, router, item.key]);
35
+ const handleSheetDismissed = useCallback(() => {
37
36
  router.unregisterSheetDismisser(item.key);
38
37
  onDismissed();
39
- };
38
+ }, [router, item.key, onDismissed]);
40
39
  return /*#__PURE__*/_jsx(RNSScreenStackItem, {
41
40
  stackPresentation: "transparentModal",
42
41
  headerConfig: headerConfig,
@@ -77,6 +77,9 @@ export const RenderSplitView = /*#__PURE__*/memo(({
77
77
  '--split-view-primary-max-width': `${splitView.primaryMaxWidth}px`
78
78
  };
79
79
  }, [splitView.primaryMaxWidth]);
80
+ const secondaryConfig = useMemo(() => ({
81
+ animateFirstScreenAfterEmpty: !isWide
82
+ }), [isWide]);
80
83
  return /*#__PURE__*/_jsx(SplitViewContext.Provider, {
81
84
  value: splitView,
82
85
  children: /*#__PURE__*/_jsxs("div", {
@@ -91,17 +94,12 @@ export const RenderSplitView = /*#__PURE__*/memo(({
91
94
  })
92
95
  }), /*#__PURE__*/_jsx("div", {
93
96
  className: "split-view-secondary",
94
- children: isWide ? /*#__PURE__*/_jsx(ScreenStackConfigContext.Provider, {
95
- value: {
96
- animateFirstScreenAfterEmpty: false
97
- },
97
+ children: /*#__PURE__*/_jsx(ScreenStackConfigContext.Provider, {
98
+ value: secondaryConfig,
98
99
  children: /*#__PURE__*/_jsx(StackSliceRenderer, {
99
100
  appearance: appearance,
100
101
  stack: splitView.secondary
101
102
  })
102
- }) : /*#__PURE__*/_jsx(StackSliceRenderer, {
103
- appearance: appearance,
104
- stack: splitView.secondary
105
103
  })
106
104
  })]
107
105
  })
@@ -35,12 +35,14 @@ class SecondaryStackWrapper {
35
35
  }
36
36
  }
37
37
  export class SplitView {
38
+ _cachedRenderer = null;
38
39
  constructor(options) {
39
40
  this.splitViewId = `splitview-${Math.random().toString(36).slice(2)}`;
40
41
  this.primary = options.primary;
41
42
  this.secondary = options.secondary;
42
43
  this.minWidth = options.minWidth;
43
44
  this.primaryMaxWidth = options.primaryMaxWidth ?? 390;
45
+ this.secondaryWrapper = new SecondaryStackWrapper(this.secondary);
44
46
  }
45
47
  getId() {
46
48
  return this.splitViewId;
@@ -54,18 +56,24 @@ export class SplitView {
54
56
  node: this.primary
55
57
  }, {
56
58
  prefix: '',
57
- node: new SecondaryStackWrapper(this.secondary)
59
+ node: this.secondaryWrapper
58
60
  }];
59
61
  }
60
62
  getRenderer() {
63
+ if (this._cachedRenderer) {
64
+ return this._cachedRenderer;
65
+ }
66
+
61
67
  // eslint-disable-next-line consistent-this
62
68
  const instance = this;
63
- return function SplitViewScreen(props) {
69
+ const renderer = function SplitViewScreen(props) {
64
70
  return /*#__PURE__*/React.createElement(RenderSplitView, {
65
71
  splitView: instance,
66
72
  appearance: props?.appearance
67
73
  });
68
74
  };
75
+ this._cachedRenderer = renderer;
76
+ return renderer;
69
77
  }
70
78
  hasRoute(routeId) {
71
79
  return this.primary.getRoutes().some(r => r.routeId === routeId) || this.secondary.getRoutes().some(r => r.routeId === routeId);
@@ -12,10 +12,16 @@ export const StackRenderer = /*#__PURE__*/memo(({
12
12
  history
13
13
  }) => {
14
14
  const router = useRouter();
15
- const subscribe = useCallback(cb => router.subscribeStack(stackId, cb), [router, stackId]);
16
- const get = useCallback(() => router.getStackHistory(stackId), [router, stackId]);
17
- const historyFromStore = useSyncExternalStore(subscribe, get, get);
18
- const historyForThisStack = history ?? historyFromStore;
15
+ const hasHistoryProp = history != null;
16
+ const subscribe = useCallback(cb => {
17
+ if (hasHistoryProp) return () => {};
18
+ return router.subscribeStack(stackId, cb);
19
+ }, [router, stackId, hasHistoryProp]);
20
+ const get = useCallback(() => {
21
+ if (hasHistoryProp) return history;
22
+ return router.getStackHistory(stackId);
23
+ }, [router, stackId, hasHistoryProp, history]);
24
+ const historyForThisStack = useSyncExternalStore(subscribe, get, get);
19
25
  return /*#__PURE__*/_jsx(ScreenStack, {
20
26
  style: [styles.flex, appearance?.screen],
21
27
  children: historyForThisStack.map(item => /*#__PURE__*/_jsx(ScreenStackItem, {
@@ -3,7 +3,7 @@
3
3
  import { StackRenderer } from "../StackRenderer.js";
4
4
  import { TabBarContext } from "./TabBarContext.js";
5
5
  import { useRouter } from "../RouterContext.js";
6
- import { BottomTabsScreen, BottomTabs, ScreenStackItem } from 'react-native-screens';
6
+ import { Tabs, ScreenStackItem } from 'react-native-screens';
7
7
  import { Platform, StyleSheet, View } from 'react-native';
8
8
  import { useCallback, useSyncExternalStore, memo, useEffect, useState, useMemo } from 'react';
9
9
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
@@ -178,7 +178,13 @@ export const RenderTabBar = /*#__PURE__*/memo(({
178
178
  title,
179
179
  backgroundColor,
180
180
  badgeBackgroundColor,
181
- iOSShadowColor
181
+ iOSShadowColor,
182
+ hidden,
183
+ tintColor,
184
+ controllerMode,
185
+ minimizeBehavior,
186
+ nativeContainerBackgroundColor,
187
+ iOSBlurEffect
182
188
  } = appearance?.tabBar ?? {};
183
189
  const onNativeFocusChange = useCallback(event => {
184
190
  const tabKey = event.nativeEvent.tabKey;
@@ -259,10 +265,11 @@ export const RenderTabBar = /*#__PURE__*/memo(({
259
265
  }
260
266
  }
261
267
  }, [tabs, tabBar, index, router]);
262
- const containerProps = {
268
+ const containerProps = useMemo(() => ({
263
269
  tabBarBackgroundColor: backgroundColor,
264
270
  tabBarItemTitleFontFamily: title?.fontFamily,
265
271
  tabBarItemTitleFontSize: title?.fontSize,
272
+ tabBarItemTitleFontSizeActive: title?.activeFontSize,
266
273
  tabBarItemTitleFontWeight: title?.fontWeight,
267
274
  tabBarItemTitleFontStyle: title?.fontStyle,
268
275
  tabBarItemTitleFontColor: title?.color,
@@ -272,36 +279,55 @@ export const RenderTabBar = /*#__PURE__*/memo(({
272
279
  tabBarItemActiveIndicatorColor: androidActiveIndicatorColor,
273
280
  tabBarItemActiveIndicatorEnabled: androidActiveIndicatorEnabled,
274
281
  tabBarItemRippleColor: androidRippleColor,
275
- tabBarItemLabelVisibilityMode: labelVisibilityMode
276
- };
277
- const iosState = {
282
+ tabBarItemLabelVisibilityMode: labelVisibilityMode,
283
+ tabBarHidden: hidden,
284
+ tabBarTintColor: tintColor,
285
+ tabBarControllerMode: controllerMode,
286
+ tabBarMinimizeBehavior: minimizeBehavior,
287
+ nativeContainerStyle: nativeContainerBackgroundColor ? {
288
+ backgroundColor: nativeContainerBackgroundColor
289
+ } : undefined
290
+ }), [backgroundColor, title?.fontFamily, title?.fontSize, title?.activeFontSize, title?.fontWeight, title?.fontStyle, title?.color, title?.activeColor, iconColor, iconColorActive, androidActiveIndicatorColor, androidActiveIndicatorEnabled, androidRippleColor, labelVisibilityMode, hidden, tintColor, controllerMode, minimizeBehavior, nativeContainerBackgroundColor]);
291
+ const iosNormalState = useMemo(() => ({
278
292
  tabBarItemTitleFontFamily: title?.fontFamily,
279
293
  tabBarItemTitleFontSize: title?.fontSize,
280
294
  tabBarItemTitleFontWeight: title?.fontWeight,
281
295
  tabBarItemTitleFontStyle: title?.fontStyle,
282
296
  tabBarItemTitleFontColor: title?.color,
283
297
  tabBarItemBadgeBackgroundColor: badgeBackgroundColor,
284
- tabBarItemTitleFontColorActive: title?.color,
285
- tabBarItemIconColorActive: iconColorActive,
286
298
  tabBarItemIconColor: iconColor
287
- };
288
- const iosAppearance = Platform.select({
299
+ }), [title?.fontFamily, title?.fontSize, title?.fontWeight, title?.fontStyle, title?.color, badgeBackgroundColor, iconColor]);
300
+ const iosSelectedState = useMemo(() => ({
301
+ tabBarItemTitleFontFamily: title?.fontFamily,
302
+ tabBarItemTitleFontSize: title?.fontSize,
303
+ tabBarItemTitleFontWeight: title?.fontWeight,
304
+ tabBarItemTitleFontStyle: title?.fontStyle,
305
+ tabBarItemTitleFontColor: title?.activeColor ?? title?.color,
306
+ tabBarItemBadgeBackgroundColor: badgeBackgroundColor,
307
+ tabBarItemIconColor: iconColorActive ?? iconColor
308
+ }), [title?.fontFamily, title?.fontSize, title?.fontWeight, title?.fontStyle, title?.color, title?.activeColor, badgeBackgroundColor, iconColorActive, iconColor]);
309
+ const iosAppearance = useMemo(() => Platform.select({
289
310
  default: undefined,
290
311
  ios: {
291
312
  tabBarBackgroundColor: backgroundColor,
292
313
  tabBarShadowColor: iOSShadowColor,
314
+ tabBarBlurEffect: iOSBlurEffect,
293
315
  compactInline: {
294
- normal: iosState
316
+ normal: iosNormalState,
317
+ selected: iosSelectedState
295
318
  },
296
319
  stacked: {
297
- normal: iosState
320
+ normal: iosNormalState,
321
+ selected: iosSelectedState
298
322
  },
299
323
  inline: {
300
- normal: iosState
324
+ normal: iosNormalState,
325
+ selected: iosSelectedState
301
326
  }
302
327
  }
303
- });
328
+ }), [backgroundColor, iOSShadowColor, iOSBlurEffect, iosNormalState, iosSelectedState]);
304
329
  const CustomTabBar = config.component;
330
+ const tabIcons = useMemo(() => tabs.map(tab => getTabIcon(tab)), [tabs]);
305
331
  const [visited, setVisited] = useState({});
306
332
  useEffect(() => {
307
333
  const key = tabs[index]?.tabKey;
@@ -357,25 +383,30 @@ export const RenderTabBar = /*#__PURE__*/memo(({
357
383
  stackAnimation: "slide_from_right",
358
384
  children: /*#__PURE__*/_jsx(TabBarContext.Provider, {
359
385
  value: tabBar,
360
- children: /*#__PURE__*/_jsx(BottomTabs, {
386
+ children: /*#__PURE__*/_jsx(Tabs.Host, {
361
387
  onNativeFocusChange: onNativeFocusChange,
388
+ bottomAccessory: config.bottomAccessory,
389
+ experimentalControlNavigationStateInJS: config.experimentalControlNavigationStateInJS,
362
390
  ...containerProps,
363
- children: tabs.map(tab => {
391
+ children: tabs.map((tab, i) => {
364
392
  const isFocused = tab.tabKey === tabs[index]?.tabKey;
365
393
  const stack = tabBar.stacks[tab.tabKey];
366
394
  const node = tabBar.nodes[tab.tabKey];
367
395
  const Screen = tabBar.screens[tab.tabKey];
368
- const icon = getTabIcon(tab);
369
- return /*#__PURE__*/_jsx(BottomTabsScreen, {
396
+ const convertedIcon = tabIcons[i];
397
+ const {
398
+ icon: _icon,
399
+ selectedIcon: _selectedIcon,
400
+ tabPrefix: _prefix,
401
+ ...tabScreenProps
402
+ } = tab;
403
+ return /*#__PURE__*/_jsx(Tabs.Screen, {
404
+ ...tabScreenProps,
370
405
  scrollEdgeAppearance: iosAppearance,
371
406
  standardAppearance: iosAppearance,
372
407
  isFocused: isFocused,
373
- tabKey: tab.tabKey,
374
- title: tab.title,
375
- badgeValue: tab.badgeValue,
376
- specialEffects: tab.specialEffects,
377
- icon: icon?.icon,
378
- selectedIcon: icon?.selectedIcon,
408
+ icon: convertedIcon?.icon,
409
+ selectedIcon: convertedIcon?.selectedIcon,
379
410
  children: stack ? /*#__PURE__*/_jsx(TabStackRenderer, {
380
411
  appearance: appearance,
381
412
  stack: stack
@@ -106,6 +106,15 @@ export const RenderTabBar = /*#__PURE__*/memo(({
106
106
  tabBar.onIndexChange(nextIndex);
107
107
  }
108
108
  }, [router, tabBar, tabs, index]);
109
+ const onKeyDown = useCallback((e, currentIndex) => {
110
+ let nextIndex = currentIndex;
111
+ if (e.key === 'ArrowRight') nextIndex = (currentIndex + 1) % tabs.length;else if (e.key === 'ArrowLeft') nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;else return;
112
+ e.preventDefault();
113
+ onTabClick(nextIndex);
114
+ const container = e.currentTarget.parentElement;
115
+ const buttons = container?.querySelectorAll('[role="tab"]');
116
+ buttons?.[nextIndex]?.focus();
117
+ }, [tabs.length, onTabClick]);
109
118
  const tabBarStyle = useMemo(() => {
110
119
  const tabBarBg = toColorString(appearance?.tabBar?.backgroundColor);
111
120
  const style = {
@@ -125,6 +134,14 @@ export const RenderTabBar = /*#__PURE__*/memo(({
125
134
  fontWeight: appearance?.tabBar?.title?.fontWeight,
126
135
  fontStyle: appearance?.tabBar?.title?.fontStyle
127
136
  }), [appearance?.tabBar?.title?.fontFamily, appearance?.tabBar?.title?.fontSize, appearance?.tabBar?.title?.fontWeight, appearance?.tabBar?.title?.fontStyle]);
137
+ const handleTabClick = useCallback(e => {
138
+ const index = Number(e.currentTarget.dataset.index);
139
+ onTabClick(index);
140
+ }, [onTabClick]);
141
+ const handleKeyDown = useCallback(e => {
142
+ const index = Number(e.currentTarget.dataset.index);
143
+ onKeyDown(e, index);
144
+ }, [onKeyDown]);
128
145
  const CustomTabBar = config.component;
129
146
  return /*#__PURE__*/_jsx(TabBarContext.Provider, {
130
147
  value: tabBar,
@@ -152,6 +169,7 @@ export const RenderTabBar = /*#__PURE__*/memo(({
152
169
  "aria-hidden": "true"
153
170
  }), /*#__PURE__*/_jsxs("div", {
154
171
  className: "tab-bar-content",
172
+ role: "tablist",
155
173
  children: [/*#__PURE__*/_jsx("div", {
156
174
  className: "tab-bar-active-indicator",
157
175
  "aria-hidden": "true"
@@ -166,11 +184,15 @@ export const RenderTabBar = /*#__PURE__*/memo(({
166
184
  };
167
185
  return /*#__PURE__*/_jsxs("button", {
168
186
  type: "button",
187
+ role: "tab",
188
+ id: `tab-${tab.tabKey}`,
169
189
  "data-index": i,
170
190
  "data-active": isActive ? 'true' : 'false',
171
- "aria-current": isActive ? 'page' : undefined,
191
+ "aria-selected": isActive,
192
+ tabIndex: isActive ? 0 : -1,
172
193
  className: `tab-item${isActive ? ' active' : ''}`,
173
- onClick: () => onTabClick(i),
194
+ onClick: handleTabClick,
195
+ onKeyDown: handleKeyDown,
174
196
  children: [/*#__PURE__*/_jsx("div", {
175
197
  className: "tab-item-icon",
176
198
  children: isImageSource(tab.icon) ? /*#__PURE__*/_jsx(TabIcon, {
@@ -183,6 +205,7 @@ export const RenderTabBar = /*#__PURE__*/memo(({
183
205
  children: tab.title
184
206
  }), tab.badgeValue ? /*#__PURE__*/_jsx("span", {
185
207
  className: "tab-item-label-badge",
208
+ "aria-label": `${tab.badgeValue} notifications`,
186
209
  children: tab.badgeValue
187
210
  }) : null]
188
211
  }, tab.tabKey);