@sigx/lynx-navigation 0.1.0 → 0.1.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.
Files changed (139) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +355 -0
  3. package/dist/components/Drawer.d.ts +56 -0
  4. package/dist/components/Drawer.d.ts.map +1 -0
  5. package/dist/components/Drawer.js +74 -0
  6. package/dist/components/Drawer.js.map +1 -0
  7. package/dist/components/EdgeBackHandle.js +144 -0
  8. package/dist/components/EdgeBackHandle.js.map +1 -0
  9. package/dist/components/EntryScope.d.ts +26 -0
  10. package/dist/components/EntryScope.d.ts.map +1 -0
  11. package/dist/components/EntryScope.js +33 -0
  12. package/dist/components/EntryScope.js.map +1 -0
  13. package/dist/components/Header.d.ts +7 -0
  14. package/dist/components/Header.d.ts.map +1 -0
  15. package/dist/components/Header.js +103 -0
  16. package/dist/components/Header.js.map +1 -0
  17. package/dist/components/Link.js +1 -4
  18. package/dist/components/Link.js.map +1 -1
  19. package/dist/components/NavigationRoot.d.ts +1 -1
  20. package/dist/components/NavigationRoot.d.ts.map +1 -1
  21. package/dist/components/NavigationRoot.js +29 -3
  22. package/dist/components/NavigationRoot.js.map +1 -1
  23. package/dist/components/Screen.d.ts +98 -0
  24. package/dist/components/Screen.d.ts.map +1 -0
  25. package/dist/components/Screen.js +94 -0
  26. package/dist/components/Screen.js.map +1 -0
  27. package/dist/components/ScreenContainer.d.ts.map +1 -1
  28. package/dist/components/ScreenContainer.js +77 -0
  29. package/dist/components/ScreenContainer.js.map +1 -0
  30. package/dist/components/Stack.d.ts.map +1 -1
  31. package/dist/components/Stack.js +60 -24
  32. package/dist/components/Stack.js.map +1 -1
  33. package/dist/components/TabBar.d.ts +40 -0
  34. package/dist/components/TabBar.d.ts.map +1 -0
  35. package/dist/components/TabBar.js +63 -0
  36. package/dist/components/TabBar.js.map +1 -0
  37. package/dist/components/Tabs.d.ts +101 -0
  38. package/dist/components/Tabs.d.ts.map +1 -0
  39. package/dist/components/Tabs.js +140 -0
  40. package/dist/components/Tabs.js.map +1 -0
  41. package/dist/hooks/use-focus.d.ts +46 -0
  42. package/dist/hooks/use-focus.d.ts.map +1 -0
  43. package/dist/hooks/use-focus.js +81 -0
  44. package/dist/hooks/use-focus.js.map +1 -0
  45. package/dist/hooks/use-hardware-back.js +50 -0
  46. package/dist/hooks/use-hardware-back.js.map +1 -0
  47. package/dist/hooks/use-linking-nav.d.ts +92 -0
  48. package/dist/hooks/use-linking-nav.d.ts.map +1 -0
  49. package/dist/hooks/use-linking-nav.js +109 -0
  50. package/dist/hooks/use-linking-nav.js.map +1 -0
  51. package/dist/hooks/use-nav-internal.d.ts +38 -1
  52. package/dist/hooks/use-nav-internal.d.ts.map +1 -1
  53. package/dist/hooks/use-nav-internal.js +32 -0
  54. package/dist/hooks/use-nav-internal.js.map +1 -1
  55. package/dist/hooks/use-nav-serializer.d.ts +83 -0
  56. package/dist/hooks/use-nav-serializer.d.ts.map +1 -0
  57. package/dist/hooks/use-nav-serializer.js +181 -0
  58. package/dist/hooks/use-nav-serializer.js.map +1 -0
  59. package/dist/hooks/use-nav.js.map +1 -1
  60. package/dist/hooks/use-screen-options.d.ts +3 -0
  61. package/dist/hooks/use-screen-options.d.ts.map +1 -0
  62. package/dist/hooks/use-screen-options.js +43 -0
  63. package/dist/hooks/use-screen-options.js.map +1 -0
  64. package/dist/href.d.ts +16 -1
  65. package/dist/href.d.ts.map +1 -1
  66. package/dist/href.js +50 -7
  67. package/dist/href.js.map +1 -1
  68. package/dist/index.d.ts +18 -1
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +15 -0
  71. package/dist/index.js.map +1 -1
  72. package/dist/internal/screen-registry.d.ts +49 -0
  73. package/dist/internal/screen-registry.d.ts.map +1 -0
  74. package/dist/internal/screen-registry.js +59 -0
  75. package/dist/internal/screen-registry.js.map +1 -0
  76. package/dist/internal/screen-width.js +30 -0
  77. package/dist/internal/screen-width.js.map +1 -0
  78. package/dist/navigator/core.d.ts +20 -1
  79. package/dist/navigator/core.d.ts.map +1 -1
  80. package/dist/navigator/core.js +231 -36
  81. package/dist/navigator/core.js.map +1 -1
  82. package/dist/types.d.ts +56 -0
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/url/build.d.ts +16 -0
  85. package/dist/url/build.d.ts.map +1 -0
  86. package/dist/url/build.js +30 -0
  87. package/dist/url/build.js.map +1 -0
  88. package/dist/url/compile.d.ts +35 -0
  89. package/dist/url/compile.d.ts.map +1 -0
  90. package/dist/url/compile.js +83 -0
  91. package/dist/url/compile.js.map +1 -0
  92. package/dist/url/format.d.ts +29 -0
  93. package/dist/url/format.d.ts.map +1 -0
  94. package/dist/url/format.js +102 -0
  95. package/dist/url/format.js.map +1 -0
  96. package/dist/url/index.d.ts +13 -0
  97. package/dist/url/index.d.ts.map +1 -0
  98. package/dist/url/index.js +13 -0
  99. package/dist/url/index.js.map +1 -0
  100. package/dist/url/parse.d.ts +21 -0
  101. package/dist/url/parse.d.ts.map +1 -0
  102. package/dist/url/parse.js +94 -0
  103. package/dist/url/parse.js.map +1 -0
  104. package/dist/url/registry.d.ts +41 -0
  105. package/dist/url/registry.d.ts.map +1 -0
  106. package/dist/url/registry.js +56 -0
  107. package/dist/url/registry.js.map +1 -0
  108. package/dist/url/validate.d.ts +24 -0
  109. package/dist/url/validate.d.ts.map +1 -0
  110. package/dist/url/validate.js +37 -0
  111. package/dist/url/validate.js.map +1 -0
  112. package/package.json +44 -15
  113. package/src/components/Drawer.tsx +119 -0
  114. package/src/components/EdgeBackHandle.tsx +1 -1
  115. package/src/components/EntryScope.tsx +38 -0
  116. package/src/components/Header.tsx +129 -0
  117. package/src/components/NavigationRoot.tsx +9 -1
  118. package/src/components/Screen.tsx +116 -0
  119. package/src/components/ScreenContainer.tsx +14 -1
  120. package/src/components/Stack.tsx +21 -2
  121. package/src/components/TabBar.tsx +104 -0
  122. package/src/components/Tabs.tsx +216 -0
  123. package/src/hooks/use-focus.ts +88 -0
  124. package/src/hooks/use-linking-nav.ts +159 -0
  125. package/src/hooks/use-nav-internal.ts +48 -1
  126. package/src/hooks/use-nav-serializer.ts +239 -0
  127. package/src/hooks/use-screen-options.ts +48 -0
  128. package/src/href.ts +68 -11
  129. package/src/index.ts +29 -0
  130. package/src/internal/screen-registry.ts +89 -0
  131. package/src/navigator/core.ts +86 -4
  132. package/src/types.ts +56 -0
  133. package/src/url/build.ts +35 -0
  134. package/src/url/compile.ts +109 -0
  135. package/src/url/format.ts +95 -0
  136. package/src/url/index.ts +18 -0
  137. package/src/url/parse.ts +102 -0
  138. package/src/url/registry.ts +69 -0
  139. package/src/url/validate.ts +67 -0
@@ -0,0 +1,119 @@
1
+ /**
2
+ * `<Drawer>` — minimal off-canvas drawer navigator.
3
+ *
4
+ * Usage:
5
+ *
6
+ * ```tsx
7
+ * <NavigationRoot routes={routes}>
8
+ * <Drawer slots={{ sidebar: () => <view><text>Menu</text></view> }}>
9
+ * <Stack />
10
+ * </Drawer>
11
+ * </NavigationRoot>
12
+ * ```
13
+ *
14
+ * `useDrawer()` from inside any descendant gives `{ isOpen, open(), close(),
15
+ * toggle() }`. The sidebar is laid out absolutely on the left and is
16
+ * visible whenever `isOpen` is true.
17
+ *
18
+ * Scope: this slice ships the state primitive + the bare-bones layout.
19
+ * Gesture-driven open (edge swipe from the left) and MTS slide-in are out
20
+ * of scope — the app shell can wrap its sidebar JSX in its own transition.
21
+ *
22
+ * Design note: the sidebar lives in a named slot (`sidebar`) rather than
23
+ * a render-prop or a `<Drawer.Sidebar>` child. Mixing
24
+ * "register-yourself-as-a-fill" children with the parent's own visible
25
+ * layout creates a feedback loop in sigx's reactive scope (the parent's
26
+ * render reads the fill, child's setup writes it, parent re-renders,
27
+ * child re-mounts, …). A scoped slot avoids that entirely and keeps the
28
+ * call site declarative.
29
+ *
30
+ * `default` slot is the main content (almost always a `<Stack>`).
31
+ */
32
+ import {
33
+ component,
34
+ defineInjectable,
35
+ defineProvide,
36
+ signal,
37
+ type Define,
38
+ type Signal,
39
+ } from '@sigx/lynx';
40
+
41
+ /** Reactive controller returned by `useDrawer()`. */
42
+ export interface DrawerNav {
43
+ /** True when the drawer is currently visible. Reactive. */
44
+ readonly isOpen: boolean;
45
+ /** Opens the drawer. */
46
+ open(): void;
47
+ /** Closes the drawer. */
48
+ close(): void;
49
+ /** Toggles between open and closed. */
50
+ toggle(): void;
51
+ }
52
+
53
+ /**
54
+ * Access the enclosing Drawer navigator. Throws when called outside
55
+ * `<Drawer>`.
56
+ */
57
+ export const useDrawer = defineInjectable<DrawerNav>(() => {
58
+ throw new Error(
59
+ '[lynx-navigation] useDrawer() called outside of a <Drawer> component.',
60
+ );
61
+ });
62
+
63
+ type DrawerProps =
64
+ & Define.Prop<'initialOpen', boolean>
65
+ & Define.Slot<'sidebar'>
66
+ & Define.Slot<'default'>;
67
+
68
+ export const Drawer = component<DrawerProps>(({ props, slots }) => {
69
+ // `isOpenSig` uses the `{value}` wrapper pattern — sigx's `signal()` of
70
+ // a primitive returns a proxy that requires `.value` reads; wrapping in
71
+ // an object makes the proxy carry a mutable boolean.
72
+ const isOpenSig: Signal<{ value: boolean }> = signal({
73
+ value: props.initialOpen === true,
74
+ });
75
+
76
+ const nav: DrawerNav = {
77
+ get isOpen() {
78
+ return isOpenSig.value;
79
+ },
80
+ open() {
81
+ isOpenSig.value = true;
82
+ },
83
+ close() {
84
+ isOpenSig.value = false;
85
+ },
86
+ toggle() {
87
+ isOpenSig.value = !isOpenSig.value;
88
+ },
89
+ };
90
+
91
+ defineProvide(useDrawer, () => nav);
92
+
93
+ return () => {
94
+ const open = isOpenSig.value;
95
+ return (
96
+ <view style={{ width: '100%', height: '100%' }}>
97
+ {/* Main content fills the whole parent. */}
98
+ <view style={{ width: '100%', height: '100%' }}>
99
+ {slots.default?.()}
100
+ </view>
101
+
102
+ {/* Sidebar is overlaid; toggled via `display`. Apps that
103
+ want an animated slide-in wrap the sidebar themselves
104
+ — the navigator just controls visibility. */}
105
+ <view
106
+ style={{
107
+ position: 'absolute',
108
+ left: 0,
109
+ top: 0,
110
+ bottom: 0,
111
+ display: open ? 'flex' : 'none',
112
+ }}
113
+ >
114
+ {slots.sidebar?.()}
115
+ </view>
116
+ </view>
117
+ );
118
+ };
119
+ });
@@ -6,7 +6,7 @@ import {
6
6
  useMainThreadRef,
7
7
  type MainThread,
8
8
  } from '@sigx/lynx';
9
- import { withTiming } from '@sigx/motion';
9
+ import { withTiming } from '@sigx/lynx-motion';
10
10
  import { useNavInternals } from '../hooks/use-nav-internal.js';
11
11
  import { SCREEN_WIDTH } from '../internal/screen-width.js';
12
12
 
@@ -0,0 +1,38 @@
1
+ import { component, defineProvide, onUnmounted, type Define } from '@sigx/lynx';
2
+ import { useCurrentEntry, useNavInternals, useScreenRegistry } from '../hooks/use-nav-internal.js';
3
+ import { createScreenRegistry } from '../internal/screen-registry.js';
4
+ import type { StackEntry } from '../types.js';
5
+
6
+ type EntryScopeProps =
7
+ & Define.Prop<'entry', StackEntry, true>
8
+ & Define.Slot<'default'>;
9
+
10
+ /**
11
+ * Provider wrapper for a single screen mount.
12
+ *
13
+ * `<Stack>` and `<ScreenContainer>` instantiate this around each route
14
+ * component so calls to `useIsFocused()` / `useFocusEffect()` /
15
+ * `<Screen>` inside that screen resolve through `useCurrentEntry()` and
16
+ * `useScreenRegistry()` to the entry it was rendered for. Without this
17
+ * wrapper there'd be no per-screen way to know "which stack entry am I?"
18
+ * — the navigator only knows what's currently on top.
19
+ *
20
+ * Also allocates a fresh `ScreenRegistry` per entry and publishes it to
21
+ * the navigator's cross-entry registry map, so persistent chrome (HeaderBar
22
+ * / TabBar — later slices) can read the focused entry's options + slot
23
+ * fills without remounting itself.
24
+ *
25
+ * Renders the default slot directly; no extra layout element is inserted,
26
+ * so this is layout-neutral for the screen it wraps.
27
+ */
28
+ export const EntryScope = component<EntryScopeProps>(({ props, slots }) => {
29
+ const internals = useNavInternals();
30
+ const registry = createScreenRegistry(props.entry);
31
+ internals.screens.register(registry);
32
+ onUnmounted(() => {
33
+ internals.screens.unregister(props.entry.key);
34
+ });
35
+ defineProvide(useCurrentEntry, () => props.entry);
36
+ defineProvide(useScreenRegistry, () => registry);
37
+ return () => slots.default?.();
38
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * `<Header>` — default navigator header chrome.
3
+ *
4
+ * Reads from the currently-focused entry's `ScreenRegistry`:
5
+ *
6
+ * - If `slots.header` is set, render that (full override).
7
+ * - Else render the default layout: headerLeft (back button when
8
+ * `nav.canGoBack`), title (from `options.title`, or the route name as
9
+ * a fallback), headerRight.
10
+ *
11
+ * Persistent: the Header component itself is mounted once near the root and
12
+ * stays mounted across navigations — it reactively switches its content
13
+ * when `nav.current` changes, rather than being remounted per screen. That
14
+ * matters because mounting cost adds to perceived transition latency.
15
+ *
16
+ * Header chrome is opt-in. Consumers place `<Header />` inside
17
+ * `<NavigationRoot>` above `<Stack />`. We don't auto-inject because:
18
+ * - app shells vary (some want the header inside a `<SafeArea>`, some
19
+ * want a custom toolbar, some want no header at all in tabs).
20
+ * - making it opt-in keeps `<Stack>`'s contract narrow.
21
+ */
22
+ import { component, computed } from '@sigx/lynx';
23
+ import { useNav } from '../hooks/use-nav.js';
24
+ import { useNavInternals } from '../hooks/use-nav-internal.js';
25
+ import type { ScreenOptions, ScreenSlotFills, StackEntry } from '../types.js';
26
+
27
+ /**
28
+ * Resolve a title (string or getter) to a plain string.
29
+ *
30
+ * Getter is the more general case; the `<Screen title={() => state.value}>`
31
+ * call site is how reactive titles work. A plain string is wrapped in a
32
+ * trivial closure so consumers always handle one shape.
33
+ */
34
+ function resolveTitle(t: ScreenOptions['title'], routeName: string): string {
35
+ if (typeof t === 'function') return t();
36
+ if (typeof t === 'string') return t;
37
+ return routeName;
38
+ }
39
+
40
+ /**
41
+ * Default back-button rendering. Plain `<text>` with a tap handler — apps
42
+ * that want an icon or a custom design override via
43
+ * `<Screen.HeaderLeft>`. Kept minimal because there's no shared icon
44
+ * primitive at the navigation layer.
45
+ */
46
+ const DefaultBackButton = component<{ onPress: () => void } & {}>(({ props }) => {
47
+ return () => (
48
+ <view
49
+ bindtap={() => props.onPress()}
50
+ accessibility-element={true}
51
+ accessibility-label="Back"
52
+ accessibility-trait="button"
53
+ >
54
+ <text>‹ Back</text>
55
+ </view>
56
+ );
57
+ });
58
+
59
+ const DefaultTitle = component<{ text: string } & {}>(({ props }) => {
60
+ return () => (
61
+ <view>
62
+ <text>{props.text}</text>
63
+ </view>
64
+ );
65
+ });
66
+
67
+ /**
68
+ * Persistent header chrome. Mount once above `<Stack>`; reactively follows
69
+ * the focused entry. No props in v1 — styling is a host-app concern,
70
+ * arrived at through the slot fills.
71
+ */
72
+ export const Header = component(() => {
73
+ const nav = useNav();
74
+ const internals = useNavInternals();
75
+
76
+ // Snapshot computeds — each one reads only what it needs so the header
77
+ // doesn't re-run wholesale on every signal touch. The slot-fill thunks
78
+ // captured by `<Screen.Header>` etc. are themselves reactive (they
79
+ // execute on every render of the consumer's tree), so re-running the
80
+ // outer template is enough to pick up downstream updates.
81
+ const currentEntry = computed<StackEntry>(() => nav.current);
82
+
83
+ const headerSlot = computed<ScreenSlotFills['header'] | undefined>(() => {
84
+ const reg = internals.screens.get(currentEntry.value.key);
85
+ return reg?.slots.header;
86
+ });
87
+ const headerLeftSlot = computed<ScreenSlotFills['headerLeft'] | undefined>(() => {
88
+ const reg = internals.screens.get(currentEntry.value.key);
89
+ return reg?.slots.headerLeft;
90
+ });
91
+ const headerRightSlot = computed<ScreenSlotFills['headerRight'] | undefined>(() => {
92
+ const reg = internals.screens.get(currentEntry.value.key);
93
+ return reg?.slots.headerRight;
94
+ });
95
+ const headerShown = computed<boolean>(() => {
96
+ const reg = internals.screens.get(currentEntry.value.key);
97
+ // Default true — most screens want a header. Opting out is one prop
98
+ // on `<Screen>`.
99
+ return reg?.options.headerShown !== false;
100
+ });
101
+ const titleText = computed<string>(() => {
102
+ const reg = internals.screens.get(currentEntry.value.key);
103
+ return resolveTitle(reg?.options.title, currentEntry.value.route);
104
+ });
105
+
106
+ return () => {
107
+ if (!headerShown.value) return null;
108
+ // Full-override path: `<Screen.Header>` supplied its own content,
109
+ // we render that and skip the default layout entirely.
110
+ const override = headerSlot.value;
111
+ if (override) return override();
112
+
113
+ return (
114
+ <view>
115
+ <view>
116
+ {headerLeftSlot.value
117
+ ? headerLeftSlot.value()
118
+ : nav.canGoBack
119
+ ? <DefaultBackButton onPress={() => nav.pop()} />
120
+ : null}
121
+ </view>
122
+ <DefaultTitle text={titleText.value} />
123
+ <view>
124
+ {headerRightSlot.value ? headerRightSlot.value() : null}
125
+ </view>
126
+ </view>
127
+ );
128
+ };
129
+ });
@@ -4,6 +4,7 @@ import { useNav } from '../hooks/use-nav.js';
4
4
  import { useNavInternals, useNavRoutes } from '../hooks/use-nav-internal.js';
5
5
  import type { RouteId } from '../register.js';
6
6
  import type { Presentation, RouteMap, StackEntry } from '../types.js';
7
+ import { _setRouteRegistry } from '../url/registry.js';
7
8
 
8
9
  type NavigationRootProps =
9
10
  & Define.Prop<'routes', RouteMap, true>
@@ -12,7 +13,7 @@ type NavigationRootProps =
12
13
  & Define.Prop<'initialSearch', Record<string, unknown>>
13
14
  /**
14
15
  * Enable slide-from-right transitions on push/pop. Defaults to true.
15
- * Tests against `@sigx/testing-lynx` (which doesn't have an MT runtime)
16
+ * Tests against `@sigx/lynx-testing` (which doesn't have an MT runtime)
16
17
  * should pass `animated={false}` so navigations commit synchronously.
17
18
  */
18
19
  & Define.Prop<'animated', boolean>
@@ -48,6 +49,12 @@ export const NavigationRoot = component<NavigationRootProps>(({ props, slots })
48
49
  `[lynx-navigation] <NavigationRoot> initialRoute='${initialName}' is not in the routes registry.`,
49
50
  );
50
51
  }
52
+ // Publish the active route registry to the URL bridge so module-level
53
+ // `hrefFor` / `parseHref` callers (deep-link handlers, anything outside
54
+ // the component tree) resolve against this navigator's routes. Last
55
+ // mount wins — multi-root apps that need isolation should call the
56
+ // URL helpers with explicit context (TBD post-1.0).
57
+ _setRouteRegistry(routes);
51
58
  const initialPresentation: Presentation = routes[initialName].presentation ?? 'card';
52
59
  const initial: StackEntry = {
53
60
  key: 'root',
@@ -79,6 +86,7 @@ export const NavigationRoot = component<NavigationRootProps>(({ props, slots })
79
86
  commitBackGesture: navState._gesture.commitBackGesture,
80
87
  cancelBackGesture: navState._gesture.cancelBackGesture,
81
88
  edgeSwipeEnabled,
89
+ screens: navState._screens,
82
90
  }));
83
91
 
84
92
  return () => slots.default?.();
@@ -0,0 +1,116 @@
1
+ /**
2
+ * `<Screen>` — declarative per-screen options + slot fills.
3
+ *
4
+ * Usage:
5
+ *
6
+ * ```tsx
7
+ * const ProfileScreen = component(() => () => (
8
+ * <Screen title="Profile" headerShown gestureEnabled>
9
+ * <Screen.HeaderRight>
10
+ * <text bindtap={onEdit}>Edit</text>
11
+ * </Screen.HeaderRight>
12
+ * <view>body…</view>
13
+ * </Screen>
14
+ * ));
15
+ * ```
16
+ *
17
+ * `<Screen>` itself renders its `default` slot inline — so the body lives
18
+ * where you'd expect with no extra layout wrapper. The sub-components
19
+ * (`Screen.Header`, `Screen.HeaderLeft`, `Screen.HeaderRight`,
20
+ * `Screen.TabBarItem`) render `null` and write into the entry's
21
+ * `ScreenRegistry`. The navigator's persistent chrome reads from there.
22
+ *
23
+ * Note: `<Screen.TabBarItem>` registers a scoped slot fill on the entry's
24
+ * `ScreenRegistry`, but the built-in `<TabBar>` doesn't read it yet — the
25
+ * fill is exposed for custom tab-bar renderers (pass `renderTab` and look
26
+ * up the active entry's registry yourself).
27
+ *
28
+ * Sub-component placement inside `<Screen>` is conventional — sigx scopes
29
+ * are by component tree, so they work anywhere under the same EntryScope.
30
+ * Placing them as direct children of `<Screen>` keeps the call site
31
+ * declarative and grep-friendly.
32
+ */
33
+ import { component, onUnmounted, type Define } from '@sigx/lynx';
34
+ import { useScreenRegistry } from '../hooks/use-nav-internal.js';
35
+ import { mergeOptions, setSlot } from '../internal/screen-registry.js';
36
+
37
+ type ScreenProps =
38
+ & Define.Prop<'title', string | (() => string)>
39
+ & Define.Prop<'headerShown', boolean>
40
+ & Define.Prop<'gestureEnabled', boolean>
41
+ & Define.Slot<'default'>;
42
+
43
+ const ScreenRoot = component<ScreenProps>(({ props, slots }) => {
44
+ const registry = useScreenRegistry();
45
+ // Apply options whenever the component sets up. Options are reactive
46
+ // through the registry's `options` signal — chrome consumers re-render
47
+ // on the next merge. We don't bother diffing here: the patch is small
48
+ // and writes only happen during setup + explicit prop changes upstream.
49
+ mergeOptions(registry, {
50
+ title: props.title,
51
+ headerShown: props.headerShown,
52
+ gestureEnabled: props.gestureEnabled,
53
+ });
54
+ return () => slots.default?.();
55
+ });
56
+
57
+ type SimpleSlotProps = Define.Slot<'default'>;
58
+
59
+ /**
60
+ * Build a sub-component that registers its `default` slot under `name` on
61
+ * the current screen's registry. Unmount removes the fill so navigating
62
+ * away from a screen with a `<Screen.HeaderRight>` clears that action.
63
+ */
64
+ function makeSlotFiller(name: 'header' | 'headerLeft' | 'headerRight') {
65
+ return component<SimpleSlotProps>(({ slots }) => {
66
+ const registry = useScreenRegistry();
67
+ setSlot(registry, name, () => slots.default?.());
68
+ onUnmounted(() => setSlot(registry, name, undefined));
69
+ return () => null;
70
+ });
71
+ }
72
+
73
+ const Header = makeSlotFiller('header');
74
+ const HeaderLeft = makeSlotFiller('headerLeft');
75
+ const HeaderRight = makeSlotFiller('headerRight');
76
+
77
+ /**
78
+ * `<Screen.TabBarItem>` — scoped slot. The default slot is a function that
79
+ * receives `{ active }`; whatever it returns is the tab-bar item content.
80
+ *
81
+ * Sigx's `Define.Slot<'default', { active: boolean }>` would express this
82
+ * directly on the component, but since `<Screen.TabBarItem>`'s parent
83
+ * (the user's tree, not the navigator) doesn't actually pass `active`, we
84
+ * accept a plain default slot whose body is itself a function. The
85
+ * navigator's TabBar invokes that function with the active flag.
86
+ */
87
+ type TabBarItemProps = Define.Slot<'default'>;
88
+
89
+ const TabBarItem = component<TabBarItemProps>(({ slots }) => {
90
+ const registry = useScreenRegistry();
91
+ setSlot(registry, 'tabBarItem', (ctx) => {
92
+ const out = slots.default?.();
93
+ // Children may be a render function `({active}) => JSX` or plain
94
+ // JSX (in which case `active` is ignored). Normalise to a value.
95
+ if (typeof out === 'function') return (out as (c: typeof ctx) => unknown)(ctx);
96
+ if (Array.isArray(out)) {
97
+ const first = out[0];
98
+ if (typeof first === 'function') return (first as (c: typeof ctx) => unknown)(ctx);
99
+ }
100
+ return out;
101
+ });
102
+ onUnmounted(() => setSlot(registry, 'tabBarItem', undefined));
103
+ return () => null;
104
+ });
105
+
106
+ /**
107
+ * Compound export. `Screen` is callable as a JSX element and exposes the
108
+ * sub-components as properties (`Screen.Header`, etc.) for the declarative
109
+ * call site shown in the file header.
110
+ */
111
+ export const Screen = Object.assign(ScreenRoot, {
112
+ Header,
113
+ HeaderLeft,
114
+ HeaderRight,
115
+ TabBarItem,
116
+ });
@@ -7,9 +7,11 @@ import {
7
7
  type MainThread,
8
8
  type SharedValue,
9
9
  } from '@sigx/lynx';
10
+ import { Suspense, isLazyComponent } from '@sigx/lynx';
10
11
  import type { MapperParams } from '@sigx/lynx';
11
12
  import { SCREEN_WIDTH } from '../internal/screen-width.js';
12
13
  import type { RouteMap, StackEntry, TransitionKind, TransitionRole } from '../types.js';
14
+ import { EntryScope } from './EntryScope.js';
13
15
 
14
16
  /**
15
17
  * Slide-from-right transition geometry. `SCREEN_WIDTH` is read from
@@ -82,6 +84,15 @@ export const ScreenContainer = component<ScreenContainerProps>(({ props }) => {
82
84
  >;
83
85
  if (typeof Comp !== 'function') return null;
84
86
  const entryParams = props.entry.params as Record<string, unknown>;
87
+ // Wrap lazy screens that declare a fallback in Suspense — see Stack.tsx
88
+ // for rationale.
89
+ const body = isLazyComponent(Comp) && route.fallback
90
+ ? (
91
+ <Suspense fallback={route.fallback as never}>
92
+ <Comp {...entryParams} />
93
+ </Suspense>
94
+ )
95
+ : <Comp {...entryParams} />;
85
96
  return (
86
97
  <view
87
98
  main-thread:ref={ref}
@@ -94,7 +105,9 @@ export const ScreenContainer = component<ScreenContainerProps>(({ props }) => {
94
105
  backgroundColor: '#0f172a',
95
106
  }}
96
107
  >
97
- <Comp key={props.entry.key} {...entryParams} />
108
+ <EntryScope key={props.entry.key} entry={props.entry}>
109
+ {body}
110
+ </EntryScope>
98
111
  </view>
99
112
  );
100
113
  };
@@ -1,8 +1,10 @@
1
1
  import { component, type ComponentFactory, type SharedValue } from '@sigx/lynx';
2
+ import { Suspense, isLazyComponent } from '@sigx/lynx';
2
3
  import { useNav } from '../hooks/use-nav.js';
3
4
  import { useNavInternals, useNavRoutes } from '../hooks/use-nav-internal.js';
4
5
  import { ScreenContainer } from './ScreenContainer.js';
5
6
  import { EdgeBackHandle } from './EdgeBackHandle.js';
7
+ import { EntryScope } from './EntryScope.js';
6
8
 
7
9
  /**
8
10
  * Stack navigator — renders the topmost stack entry's component at rest, or
@@ -41,6 +43,17 @@ export const Stack = component(() => {
41
43
  >;
42
44
  if (typeof Comp !== 'function') return null;
43
45
  const params = top.params as Record<string, unknown>;
46
+ // Wrap lazy routes that declare a `fallback` in <Suspense> so the
47
+ // chunk-load shows the user-provided spinner instead of throwing
48
+ // up to the nearest outer boundary (which may be wrong layer or
49
+ // missing entirely).
50
+ const body = isLazyComponent(Comp) && route.fallback
51
+ ? (
52
+ <Suspense fallback={route.fallback as never}>
53
+ <Comp {...params} />
54
+ </Suspense>
55
+ )
56
+ : <Comp {...params} />;
44
57
  // When canGoBack and edge-swipe is enabled, overlay the gesture
45
58
  // handle so the user can pan from the left edge to start a back
46
59
  // transition. `position: absolute` doesn't disturb the screen's
@@ -55,12 +68,18 @@ export const Stack = component(() => {
55
68
  height: '100%',
56
69
  }}
57
70
  >
58
- <Comp key={top.key} {...params} />
71
+ <EntryScope key={top.key} entry={top}>
72
+ {body}
73
+ </EntryScope>
59
74
  <EdgeBackHandle key="edge-back" />
60
75
  </view>
61
76
  );
62
77
  }
63
- return <Comp key={top.key} {...params} />;
78
+ return (
79
+ <EntryScope key={top.key} entry={top}>
80
+ {body}
81
+ </EntryScope>
82
+ );
64
83
  }
65
84
 
66
85
  // Cast progress: TransitionState carries it as `unknown` to avoid
@@ -0,0 +1,104 @@
1
+ /**
2
+ * `<TabBar>` — default chrome for `<Tabs>`.
3
+ *
4
+ * Renders a row of tab buttons reading from the enclosing `useTabs()`
5
+ * navigator. Active tab is highlighted via the `active` prop on each
6
+ * default button (consumers can re-style via `renderTab`).
7
+ *
8
+ * Customization knobs:
9
+ * - `renderTab`: a function `(info, ctx) => JSX` that fully replaces the
10
+ * default button rendering for each tab. `ctx.active` tells the
11
+ * consumer whether this tab is currently focused; `ctx.onPress`
12
+ * activates the tab.
13
+ *
14
+ * Accessibility:
15
+ * - Each default button gets `accessibility-label` from
16
+ * `info.accessibilityLabel ?? info.label ?? info.name`.
17
+ * - Each default button gets `accessibility-element="true"` so screen
18
+ * readers see the whole pill, not just the inner `<text>`.
19
+ * - Each default button gets `accessibility-trait="button"` and a
20
+ * `selected` flag on the active one so VoiceOver/TalkBack announces
21
+ * focus state on tab switch.
22
+ *
23
+ * Placement: mount inside `<Tabs>` alongside the `<Tabs.Screen>`s. Order
24
+ * matters visually (place above or below the screen bodies depending on
25
+ * the layout), and `<Tabs.Screen>` bodies all stack with `display:flex` so
26
+ * the TabBar should be at a deterministic position in the JSX.
27
+ */
28
+ import {
29
+ component,
30
+ type Define,
31
+ type JSXElement,
32
+ } from '@sigx/lynx';
33
+ import { useTabs, type TabInfo } from './Tabs.js';
34
+
35
+ /** Rendering context passed to a `renderTab` consumer. */
36
+ export interface TabRenderContext {
37
+ /** True when this tab is currently active. Reactive — re-runs render on change. */
38
+ readonly active: boolean;
39
+ /** Activates this tab. Use as a `bindtap` handler on the rendered node. */
40
+ onPress(): void;
41
+ }
42
+
43
+ type TabBarProps =
44
+ & Define.Prop<'renderTab', (info: TabInfo, ctx: TabRenderContext) => JSXElement>;
45
+
46
+ /**
47
+ * Default per-tab button. Plain `<view>` with a `<text>` inside, an
48
+ * `accessibility-*` cluster for screen readers, and a tap handler. No
49
+ * styling beyond a minimal active-state marker — consumers that want
50
+ * branded chrome pass `renderTab`.
51
+ */
52
+ const DefaultTabButton = component<
53
+ & Define.Prop<'info', TabInfo, true>
54
+ & Define.Prop<'active', boolean, true>
55
+ & Define.Prop<'onPress', () => void, true>
56
+ >(({ props }) => {
57
+ return () => {
58
+ const label = props.info.label ?? props.info.name;
59
+ const a11y = props.info.accessibilityLabel ?? label;
60
+ return (
61
+ <view
62
+ bindtap={() => props.onPress()}
63
+ accessibility-element={true}
64
+ accessibility-label={a11y}
65
+ accessibility-trait="button"
66
+ accessibility-status={props.active ? 'selected' : undefined}
67
+ style={{ opacity: props.active ? 1 : 0.6 }}
68
+ >
69
+ {props.info.icon ?? null}
70
+ <text>{label}</text>
71
+ </view>
72
+ );
73
+ };
74
+ });
75
+
76
+ export const TabBar = component<TabBarProps>(({ props }) => {
77
+ const nav = useTabs();
78
+ return () => {
79
+ // Reading `nav.tabs` and `nav.active` here ties this render to both
80
+ // the registration list and the active signal — switching active or
81
+ // adding/removing a `<Tabs.Screen>` updates the bar reactively.
82
+ const tabs = nav.tabs;
83
+ const active = nav.active;
84
+ const renderer = props.renderTab;
85
+ return (
86
+ <view accessibility-element={false}>
87
+ {tabs.map((info) => {
88
+ const isActive = info.name === active;
89
+ const onPress = () => nav.setActive(info.name);
90
+ if (renderer) {
91
+ return renderer(info, { active: isActive, onPress });
92
+ }
93
+ return (
94
+ <DefaultTabButton
95
+ info={info}
96
+ active={isActive}
97
+ onPress={onPress}
98
+ />
99
+ );
100
+ })}
101
+ </view>
102
+ );
103
+ };
104
+ });