@sigmela/router 0.2.8 → 0.3.0

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 (38) hide show
  1. package/lib/module/Drawer/Drawer.js +250 -0
  2. package/lib/module/Drawer/DrawerContext.js +4 -0
  3. package/lib/module/Drawer/DrawerIcon.web.js +47 -0
  4. package/lib/module/Drawer/RenderDrawer.native.js +241 -0
  5. package/lib/module/Drawer/RenderDrawer.web.js +197 -0
  6. package/lib/module/Drawer/useDrawer.js +11 -0
  7. package/lib/module/Navigation.js +4 -2
  8. package/lib/module/NavigationStack.js +14 -4
  9. package/lib/module/Router.js +214 -60
  10. package/lib/module/RouterContext.js +1 -1
  11. package/lib/module/ScreenStack/ScreenStack.web.js +78 -12
  12. package/lib/module/ScreenStackItem/ScreenStackItem.js +7 -7
  13. package/lib/module/ScreenStackItem/ScreenStackItem.web.js +65 -6
  14. package/lib/module/ScreenStackSheetItem/ScreenStackSheetItem.native.js +4 -5
  15. package/lib/module/SplitView/RenderSplitView.web.js +5 -7
  16. package/lib/module/SplitView/SplitView.js +10 -2
  17. package/lib/module/StackRenderer.js +10 -4
  18. package/lib/module/TabBar/RenderTabBar.native.js +10 -9
  19. package/lib/module/TabBar/RenderTabBar.web.js +25 -2
  20. package/lib/module/TabBar/TabBar.js +8 -1
  21. package/lib/module/TabBar/TabIcon.web.js +12 -7
  22. package/lib/module/index.js +2 -0
  23. package/lib/module/styles.css +246 -91
  24. package/lib/typescript/src/Drawer/Drawer.d.ts +100 -0
  25. package/lib/typescript/src/Drawer/DrawerContext.d.ts +3 -0
  26. package/lib/typescript/src/Drawer/DrawerIcon.web.d.ts +7 -0
  27. package/lib/typescript/src/Drawer/RenderDrawer.native.d.ts +8 -0
  28. package/lib/typescript/src/Drawer/RenderDrawer.web.d.ts +8 -0
  29. package/lib/typescript/src/Drawer/useDrawer.d.ts +2 -0
  30. package/lib/typescript/src/NavigationStack.d.ts +1 -0
  31. package/lib/typescript/src/Router.d.ts +13 -0
  32. package/lib/typescript/src/ScreenStack/ScreenStackContext.d.ts +1 -1
  33. package/lib/typescript/src/ScreenStack/animationHelpers.d.ts +1 -1
  34. package/lib/typescript/src/SplitView/SplitView.d.ts +2 -0
  35. package/lib/typescript/src/TabBar/TabBar.d.ts +1 -0
  36. package/lib/typescript/src/index.d.ts +5 -0
  37. package/lib/typescript/src/types.d.ts +1 -1
  38. package/package.json +15 -4
@@ -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, {
@@ -259,7 +259,7 @@ export const RenderTabBar = /*#__PURE__*/memo(({
259
259
  }
260
260
  }
261
261
  }, [tabs, tabBar, index, router]);
262
- const containerProps = {
262
+ const containerProps = useMemo(() => ({
263
263
  tabBarBackgroundColor: backgroundColor,
264
264
  tabBarItemTitleFontFamily: title?.fontFamily,
265
265
  tabBarItemTitleFontSize: title?.fontSize,
@@ -273,19 +273,19 @@ export const RenderTabBar = /*#__PURE__*/memo(({
273
273
  tabBarItemActiveIndicatorEnabled: androidActiveIndicatorEnabled,
274
274
  tabBarItemRippleColor: androidRippleColor,
275
275
  tabBarItemLabelVisibilityMode: labelVisibilityMode
276
- };
277
- const iosState = {
276
+ }), [backgroundColor, title?.fontFamily, title?.fontSize, title?.fontWeight, title?.fontStyle, title?.color, title?.activeColor, iconColor, iconColorActive, androidActiveIndicatorColor, androidActiveIndicatorEnabled, androidRippleColor, labelVisibilityMode]);
277
+ const iosState = useMemo(() => ({
278
278
  tabBarItemTitleFontFamily: title?.fontFamily,
279
279
  tabBarItemTitleFontSize: title?.fontSize,
280
280
  tabBarItemTitleFontWeight: title?.fontWeight,
281
281
  tabBarItemTitleFontStyle: title?.fontStyle,
282
282
  tabBarItemTitleFontColor: title?.color,
283
283
  tabBarItemBadgeBackgroundColor: badgeBackgroundColor,
284
- tabBarItemTitleFontColorActive: title?.color,
284
+ tabBarItemTitleFontColorActive: title?.activeColor ?? title?.color,
285
285
  tabBarItemIconColorActive: iconColorActive,
286
286
  tabBarItemIconColor: iconColor
287
- };
288
- const iosAppearance = Platform.select({
287
+ }), [title?.fontFamily, title?.fontSize, title?.fontWeight, title?.fontStyle, title?.color, title?.activeColor, badgeBackgroundColor, iconColorActive, iconColor]);
288
+ const iosAppearance = useMemo(() => Platform.select({
289
289
  default: undefined,
290
290
  ios: {
291
291
  tabBarBackgroundColor: backgroundColor,
@@ -300,8 +300,9 @@ export const RenderTabBar = /*#__PURE__*/memo(({
300
300
  normal: iosState
301
301
  }
302
302
  }
303
- });
303
+ }), [backgroundColor, iOSShadowColor, iosState]);
304
304
  const CustomTabBar = config.component;
305
+ const tabIcons = useMemo(() => tabs.map(tab => getTabIcon(tab)), [tabs]);
305
306
  const [visited, setVisited] = useState({});
306
307
  useEffect(() => {
307
308
  const key = tabs[index]?.tabKey;
@@ -360,12 +361,12 @@ export const RenderTabBar = /*#__PURE__*/memo(({
360
361
  children: /*#__PURE__*/_jsx(BottomTabs, {
361
362
  onNativeFocusChange: onNativeFocusChange,
362
363
  ...containerProps,
363
- children: tabs.map(tab => {
364
+ children: tabs.map((tab, i) => {
364
365
  const isFocused = tab.tabKey === tabs[index]?.tabKey;
365
366
  const stack = tabBar.stacks[tab.tabKey];
366
367
  const node = tabBar.nodes[tab.tabKey];
367
368
  const Screen = tabBar.screens[tab.tabKey];
368
- const icon = getTabIcon(tab);
369
+ const icon = tabIcons[i];
369
370
  return /*#__PURE__*/_jsx(BottomTabsScreen, {
370
371
  scrollEdgeAppearance: iosAppearance,
371
372
  standardAppearance: iosAppearance,
@@ -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);
@@ -10,6 +10,7 @@ export class TabBar {
10
10
  stacks = {};
11
11
  nodes = {};
12
12
  listeners = new Set();
13
+ _cachedRenderer = null;
13
14
  constructor(options = {}) {
14
15
  this.tabBarId = `tabbar-${Math.random().toString(36).slice(2)}`;
15
16
  this.state = {
@@ -146,14 +147,20 @@ export class TabBar {
146
147
  return children;
147
148
  }
148
149
  getRenderer() {
150
+ if (this._cachedRenderer) {
151
+ return this._cachedRenderer;
152
+ }
153
+
149
154
  // eslint-disable-next-line consistent-this
150
155
  const tabBarInstance = this;
151
- return function TabBarScreen(props) {
156
+ const renderer = function TabBarScreen(props) {
152
157
  return /*#__PURE__*/React.createElement(RenderTabBar, {
153
158
  tabBar: tabBarInstance,
154
159
  appearance: props?.appearance
155
160
  });
156
161
  };
162
+ this._cachedRenderer = renderer;
163
+ return renderer;
157
164
  }
158
165
  seed() {
159
166
  const activeTab = this.state.tabs[this.state.index];
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
 
3
- import { memo } from 'react';
3
+ import { memo, useMemo } from 'react';
4
4
  import { Image } from 'react-native';
5
5
  import { jsx as _jsx } from "react/jsx-runtime";
6
6
  const resolveImageUri = source => {
@@ -20,18 +20,23 @@ export const TabIcon = /*#__PURE__*/memo(({
20
20
  }) => {
21
21
  const iconUri = resolveImageUri(source);
22
22
  const useMask = Boolean(tintColor && iconUri);
23
- if (useMask && iconUri) {
24
- const maskStyle = {
23
+ const maskStyle = useMemo(() => {
24
+ if (!useMask || !iconUri) return undefined;
25
+ return {
25
26
  backgroundColor: tintColor,
26
27
  WebkitMaskImage: `url(${iconUri})`,
27
- maskImage: `url(${iconUri})`,
28
28
  WebkitMaskSize: 'contain',
29
- maskSize: 'contain',
30
29
  WebkitMaskRepeat: 'no-repeat',
31
- maskRepeat: 'no-repeat',
32
30
  WebkitMaskPosition: 'center',
33
- maskPosition: 'center'
31
+ maskImage: `url(${iconUri})`,
32
+ maskSize: 'contain',
33
+ maskRepeat: 'no-repeat',
34
+ maskPosition: 'center',
35
+ width: '100%',
36
+ height: '100%'
34
37
  };
38
+ }, [tintColor, iconUri, useMask]);
39
+ if (maskStyle) {
35
40
  return /*#__PURE__*/_jsx("div", {
36
41
  style: maskStyle
37
42
  });
@@ -3,6 +3,8 @@
3
3
  export { useTabBar } from "./TabBar/useTabBar.js";
4
4
  export { useTabBarHeight, TAB_BAR_HEIGHT } from "./TabBar/useTabBarHeight.js";
5
5
  export { TabBar } from "./TabBar/TabBar.js";
6
+ export { Drawer } from "./Drawer/Drawer.js";
7
+ export { useDrawer } from "./Drawer/useDrawer.js";
6
8
  export { SplitView } from "./SplitView/SplitView.js";
7
9
  export { useSplitView } from "./SplitView/useSplitView.js";
8
10
  export { Router } from "./Router.js";