@sigx/lynx-navigation 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +355 -0
- package/dist/components/Drawer.d.ts +58 -0
- package/dist/components/Drawer.d.ts.map +1 -0
- package/dist/components/Drawer.js +76 -0
- package/dist/components/Drawer.js.map +1 -0
- package/dist/components/EdgeBackHandle.js +144 -0
- package/dist/components/EdgeBackHandle.js.map +1 -0
- package/dist/components/EntryScope.d.ts +26 -0
- package/dist/components/EntryScope.d.ts.map +1 -0
- package/dist/components/EntryScope.js +33 -0
- package/dist/components/EntryScope.js.map +1 -0
- package/dist/components/Header.d.ts +7 -0
- package/dist/components/Header.d.ts.map +1 -0
- package/dist/components/Header.js +103 -0
- package/dist/components/Header.js.map +1 -0
- package/dist/components/Link.js +1 -4
- package/dist/components/Link.js.map +1 -1
- package/dist/components/NavigationRoot.d.ts +1 -1
- package/dist/components/NavigationRoot.d.ts.map +1 -1
- package/dist/components/NavigationRoot.js +29 -3
- package/dist/components/NavigationRoot.js.map +1 -1
- package/dist/components/Screen.d.ts +98 -0
- package/dist/components/Screen.d.ts.map +1 -0
- package/dist/components/Screen.js +94 -0
- package/dist/components/Screen.js.map +1 -0
- package/dist/components/ScreenContainer.d.ts.map +1 -1
- package/dist/components/ScreenContainer.js +77 -0
- package/dist/components/ScreenContainer.js.map +1 -0
- package/dist/components/Stack.d.ts.map +1 -1
- package/dist/components/Stack.js +60 -24
- package/dist/components/Stack.js.map +1 -1
- package/dist/components/TabBar.d.ts +40 -0
- package/dist/components/TabBar.d.ts.map +1 -0
- package/dist/components/TabBar.js +63 -0
- package/dist/components/TabBar.js.map +1 -0
- package/dist/components/Tabs.d.ts +101 -0
- package/dist/components/Tabs.d.ts.map +1 -0
- package/dist/components/Tabs.js +135 -0
- package/dist/components/Tabs.js.map +1 -0
- package/dist/hooks/use-focus.d.ts +46 -0
- package/dist/hooks/use-focus.d.ts.map +1 -0
- package/dist/hooks/use-focus.js +77 -0
- package/dist/hooks/use-focus.js.map +1 -0
- package/dist/hooks/use-hardware-back.js +50 -0
- package/dist/hooks/use-hardware-back.js.map +1 -0
- package/dist/hooks/use-linking-nav.d.ts +92 -0
- package/dist/hooks/use-linking-nav.d.ts.map +1 -0
- package/dist/hooks/use-linking-nav.js +109 -0
- package/dist/hooks/use-linking-nav.js.map +1 -0
- package/dist/hooks/use-nav-internal.d.ts +38 -1
- package/dist/hooks/use-nav-internal.d.ts.map +1 -1
- package/dist/hooks/use-nav-internal.js +32 -0
- package/dist/hooks/use-nav-internal.js.map +1 -1
- package/dist/hooks/use-nav-serializer.d.ts +83 -0
- package/dist/hooks/use-nav-serializer.d.ts.map +1 -0
- package/dist/hooks/use-nav-serializer.js +181 -0
- package/dist/hooks/use-nav-serializer.js.map +1 -0
- package/dist/hooks/use-nav.js.map +1 -1
- package/dist/hooks/use-screen-options.d.ts +3 -0
- package/dist/hooks/use-screen-options.d.ts.map +1 -0
- package/dist/hooks/use-screen-options.js +43 -0
- package/dist/hooks/use-screen-options.js.map +1 -0
- package/dist/href.d.ts +16 -1
- package/dist/href.d.ts.map +1 -1
- package/dist/href.js +50 -7
- package/dist/href.js.map +1 -1
- package/dist/index.d.ts +18 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/screen-registry.d.ts +49 -0
- package/dist/internal/screen-registry.d.ts.map +1 -0
- package/dist/internal/screen-registry.js +59 -0
- package/dist/internal/screen-registry.js.map +1 -0
- package/dist/internal/screen-width.js +30 -0
- package/dist/internal/screen-width.js.map +1 -0
- package/dist/navigator/core.d.ts +20 -1
- package/dist/navigator/core.d.ts.map +1 -1
- package/dist/navigator/core.js +231 -36
- package/dist/navigator/core.js.map +1 -1
- package/dist/types.d.ts +56 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/url/build.d.ts +16 -0
- package/dist/url/build.d.ts.map +1 -0
- package/dist/url/build.js +30 -0
- package/dist/url/build.js.map +1 -0
- package/dist/url/compile.d.ts +35 -0
- package/dist/url/compile.d.ts.map +1 -0
- package/dist/url/compile.js +83 -0
- package/dist/url/compile.js.map +1 -0
- package/dist/url/format.d.ts +26 -0
- package/dist/url/format.d.ts.map +1 -0
- package/dist/url/format.js +99 -0
- package/dist/url/format.js.map +1 -0
- package/dist/url/index.d.ts +13 -0
- package/dist/url/index.d.ts.map +1 -0
- package/dist/url/index.js +13 -0
- package/dist/url/index.js.map +1 -0
- package/dist/url/parse.d.ts +21 -0
- package/dist/url/parse.d.ts.map +1 -0
- package/dist/url/parse.js +90 -0
- package/dist/url/parse.js.map +1 -0
- package/dist/url/registry.d.ts +41 -0
- package/dist/url/registry.d.ts.map +1 -0
- package/dist/url/registry.js +56 -0
- package/dist/url/registry.js.map +1 -0
- package/dist/url/validate.d.ts +24 -0
- package/dist/url/validate.d.ts.map +1 -0
- package/dist/url/validate.js +37 -0
- package/dist/url/validate.js.map +1 -0
- package/package.json +44 -15
- package/src/components/Drawer.tsx +121 -0
- package/src/components/EdgeBackHandle.tsx +1 -1
- package/src/components/EntryScope.tsx +38 -0
- package/src/components/Header.tsx +124 -0
- package/src/components/NavigationRoot.tsx +9 -1
- package/src/components/Screen.tsx +116 -0
- package/src/components/ScreenContainer.tsx +14 -1
- package/src/components/Stack.tsx +21 -2
- package/src/components/TabBar.tsx +103 -0
- package/src/components/Tabs.tsx +212 -0
- package/src/hooks/use-focus.ts +77 -0
- package/src/hooks/use-linking-nav.ts +159 -0
- package/src/hooks/use-nav-internal.ts +48 -1
- package/src/hooks/use-nav-serializer.ts +239 -0
- package/src/hooks/use-screen-options.ts +48 -0
- package/src/href.ts +68 -11
- package/src/index.ts +29 -0
- package/src/internal/screen-registry.ts +89 -0
- package/src/navigator/core.ts +86 -4
- package/src/types.ts +56 -0
- package/src/url/build.ts +35 -0
- package/src/url/compile.ts +109 -0
- package/src/url/format.ts +92 -0
- package/src/url/index.ts +18 -0
- package/src/url/parse.ts +97 -0
- package/src/url/registry.ts +69 -0
- package/src/url/validate.ts +67 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<Drawer>` — minimal off-canvas drawer navigator.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
*
|
|
6
|
+
* ```tsx
|
|
7
|
+
* <NavigationRoot routes={routes}>
|
|
8
|
+
* <Drawer
|
|
9
|
+
* sidebar={() => <view><text>Menu</text></view>}
|
|
10
|
+
* >
|
|
11
|
+
* <Stack />
|
|
12
|
+
* </Drawer>
|
|
13
|
+
* </NavigationRoot>
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* `useDrawer()` from inside any descendant gives `{ isOpen, open(), close(),
|
|
17
|
+
* toggle() }`. The sidebar is laid out absolutely on the left and is
|
|
18
|
+
* visible whenever `isOpen` is true.
|
|
19
|
+
*
|
|
20
|
+
* Scope: this slice ships the state primitive + the bare-bones layout.
|
|
21
|
+
* Gesture-driven open (edge swipe from the left) and MTS slide-in are out
|
|
22
|
+
* of scope — the app shell can wrap its sidebar JSX in its own transition.
|
|
23
|
+
*
|
|
24
|
+
* Design note: the sidebar is passed as a render function via the
|
|
25
|
+
* `sidebar` slot prop rather than a `<Drawer.Sidebar>` child. Mixing
|
|
26
|
+
* "register-yourself-as-a-fill" children with the parent's own visible
|
|
27
|
+
* layout creates a feedback loop in sigx's reactive scope (the parent's
|
|
28
|
+
* render reads the fill, child's setup writes it, parent re-renders,
|
|
29
|
+
* child re-mounts, …). A scoped slot avoids that entirely and the API
|
|
30
|
+
* is identical at the call site.
|
|
31
|
+
*
|
|
32
|
+
* `default` slot is the main content (almost always a `<Stack>`).
|
|
33
|
+
*/
|
|
34
|
+
import {
|
|
35
|
+
component,
|
|
36
|
+
defineInjectable,
|
|
37
|
+
defineProvide,
|
|
38
|
+
signal,
|
|
39
|
+
type Define,
|
|
40
|
+
type Signal,
|
|
41
|
+
} from '@sigx/lynx';
|
|
42
|
+
|
|
43
|
+
/** Reactive controller returned by `useDrawer()`. */
|
|
44
|
+
export interface DrawerNav {
|
|
45
|
+
/** True when the drawer is currently visible. Reactive. */
|
|
46
|
+
readonly isOpen: boolean;
|
|
47
|
+
/** Opens the drawer. */
|
|
48
|
+
open(): void;
|
|
49
|
+
/** Closes the drawer. */
|
|
50
|
+
close(): void;
|
|
51
|
+
/** Toggles between open and closed. */
|
|
52
|
+
toggle(): void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Access the enclosing Drawer navigator. Throws when called outside
|
|
57
|
+
* `<Drawer>`.
|
|
58
|
+
*/
|
|
59
|
+
export const useDrawer = defineInjectable<DrawerNav>(() => {
|
|
60
|
+
throw new Error(
|
|
61
|
+
'[lynx-navigation] useDrawer() called outside of a <Drawer> component.',
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
type DrawerProps =
|
|
66
|
+
& Define.Prop<'initialOpen', boolean>
|
|
67
|
+
& Define.Slot<'sidebar'>
|
|
68
|
+
& Define.Slot<'default'>;
|
|
69
|
+
|
|
70
|
+
export const Drawer = component<DrawerProps>(({ props, slots }) => {
|
|
71
|
+
// `isOpenSig` uses the `{value}` wrapper pattern — sigx's `signal()` of
|
|
72
|
+
// a primitive returns a proxy that requires `.value` reads; wrapping in
|
|
73
|
+
// an object makes the proxy carry a mutable boolean.
|
|
74
|
+
const isOpenSig: Signal<{ value: boolean }> = signal({
|
|
75
|
+
value: props.initialOpen === true,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const nav: DrawerNav = {
|
|
79
|
+
get isOpen() {
|
|
80
|
+
return isOpenSig.value;
|
|
81
|
+
},
|
|
82
|
+
open() {
|
|
83
|
+
isOpenSig.value = true;
|
|
84
|
+
},
|
|
85
|
+
close() {
|
|
86
|
+
isOpenSig.value = false;
|
|
87
|
+
},
|
|
88
|
+
toggle() {
|
|
89
|
+
isOpenSig.value = !isOpenSig.value;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
defineProvide(useDrawer, () => nav);
|
|
94
|
+
|
|
95
|
+
return () => {
|
|
96
|
+
const open = isOpenSig.value;
|
|
97
|
+
return (
|
|
98
|
+
<view style={{ width: '100%', height: '100%' }}>
|
|
99
|
+
{/* Main content fills the whole parent. */}
|
|
100
|
+
<view style={{ width: '100%', height: '100%' }}>
|
|
101
|
+
{slots.default?.()}
|
|
102
|
+
</view>
|
|
103
|
+
|
|
104
|
+
{/* Sidebar is overlaid; toggled via `display`. Apps that
|
|
105
|
+
want an animated slide-in wrap the sidebar themselves
|
|
106
|
+
— the navigator just controls visibility. */}
|
|
107
|
+
<view
|
|
108
|
+
style={{
|
|
109
|
+
position: 'absolute',
|
|
110
|
+
left: 0,
|
|
111
|
+
top: 0,
|
|
112
|
+
bottom: 0,
|
|
113
|
+
display: open ? 'flex' : 'none',
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
{slots.sidebar?.()}
|
|
117
|
+
</view>
|
|
118
|
+
</view>
|
|
119
|
+
);
|
|
120
|
+
};
|
|
121
|
+
});
|
|
@@ -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,124 @@
|
|
|
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 bindtap={() => props.onPress()}>
|
|
49
|
+
<text>‹ Back</text>
|
|
50
|
+
</view>
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const DefaultTitle = component<{ text: string } & {}>(({ props }) => {
|
|
55
|
+
return () => (
|
|
56
|
+
<view>
|
|
57
|
+
<text>{props.text}</text>
|
|
58
|
+
</view>
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Persistent header chrome. Mount once above `<Stack>`; reactively follows
|
|
64
|
+
* the focused entry. No props in v1 — styling is a host-app concern,
|
|
65
|
+
* arrived at through the slot fills.
|
|
66
|
+
*/
|
|
67
|
+
export const Header = component(() => {
|
|
68
|
+
const nav = useNav();
|
|
69
|
+
const internals = useNavInternals();
|
|
70
|
+
|
|
71
|
+
// Snapshot computeds — each one reads only what it needs so the header
|
|
72
|
+
// doesn't re-run wholesale on every signal touch. The slot-fill thunks
|
|
73
|
+
// captured by `<Screen.Header>` etc. are themselves reactive (they
|
|
74
|
+
// execute on every render of the consumer's tree), so re-running the
|
|
75
|
+
// outer template is enough to pick up downstream updates.
|
|
76
|
+
const currentEntry = computed<StackEntry>(() => nav.current);
|
|
77
|
+
|
|
78
|
+
const headerSlot = computed<ScreenSlotFills['header'] | undefined>(() => {
|
|
79
|
+
const reg = internals.screens.get(currentEntry.value.key);
|
|
80
|
+
return reg?.slots.header;
|
|
81
|
+
});
|
|
82
|
+
const headerLeftSlot = computed<ScreenSlotFills['headerLeft'] | undefined>(() => {
|
|
83
|
+
const reg = internals.screens.get(currentEntry.value.key);
|
|
84
|
+
return reg?.slots.headerLeft;
|
|
85
|
+
});
|
|
86
|
+
const headerRightSlot = computed<ScreenSlotFills['headerRight'] | undefined>(() => {
|
|
87
|
+
const reg = internals.screens.get(currentEntry.value.key);
|
|
88
|
+
return reg?.slots.headerRight;
|
|
89
|
+
});
|
|
90
|
+
const headerShown = computed<boolean>(() => {
|
|
91
|
+
const reg = internals.screens.get(currentEntry.value.key);
|
|
92
|
+
// Default true — most screens want a header. Opting out is one prop
|
|
93
|
+
// on `<Screen>`.
|
|
94
|
+
return reg?.options.headerShown !== false;
|
|
95
|
+
});
|
|
96
|
+
const titleText = computed<string>(() => {
|
|
97
|
+
const reg = internals.screens.get(currentEntry.value.key);
|
|
98
|
+
return resolveTitle(reg?.options.title, currentEntry.value.route);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return () => {
|
|
102
|
+
if (!headerShown.value) return null;
|
|
103
|
+
// Full-override path: `<Screen.Header>` supplied its own content,
|
|
104
|
+
// we render that and skip the default layout entirely.
|
|
105
|
+
const override = headerSlot.value;
|
|
106
|
+
if (override) return override();
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<view>
|
|
110
|
+
<view>
|
|
111
|
+
{headerLeftSlot.value
|
|
112
|
+
? headerLeftSlot.value()
|
|
113
|
+
: nav.canGoBack
|
|
114
|
+
? <DefaultBackButton onPress={() => nav.pop()} />
|
|
115
|
+
: null}
|
|
116
|
+
</view>
|
|
117
|
+
<DefaultTitle text={titleText.value} />
|
|
118
|
+
<view>
|
|
119
|
+
{headerRightSlot.value ? headerRightSlot.value() : null}
|
|
120
|
+
</view>
|
|
121
|
+
</view>
|
|
122
|
+
);
|
|
123
|
+
};
|
|
124
|
+
});
|
|
@@ -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
|
|
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
|
+
* <Screen.TabBarItem>
|
|
13
|
+
* {({ active }) => (
|
|
14
|
+
* <text style={{ color: active ? '#007aff' : '#888' }}>Me</text>
|
|
15
|
+
* )}
|
|
16
|
+
* </Screen.TabBarItem>
|
|
17
|
+
* <view>body…</view>
|
|
18
|
+
* </Screen>
|
|
19
|
+
* ));
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* `<Screen>` itself renders its `default` slot inline — so the body lives
|
|
23
|
+
* where you'd expect with no extra layout wrapper. The sub-components
|
|
24
|
+
* (`Screen.Header`, `Screen.HeaderLeft`, `Screen.HeaderRight`,
|
|
25
|
+
* `Screen.TabBarItem`) render `null` and write into the entry's
|
|
26
|
+
* `ScreenRegistry`. The navigator's persistent chrome reads from there.
|
|
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
|
-
<
|
|
108
|
+
<EntryScope key={props.entry.key} entry={props.entry}>
|
|
109
|
+
{body}
|
|
110
|
+
</EntryScope>
|
|
98
111
|
</view>
|
|
99
112
|
);
|
|
100
113
|
};
|
package/src/components/Stack.tsx
CHANGED
|
@@ -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
|
-
<
|
|
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
|
|
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,103 @@
|
|
|
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
|
+
style={{ opacity: props.active ? 1 : 0.6 }}
|
|
67
|
+
>
|
|
68
|
+
{props.info.icon ?? null}
|
|
69
|
+
<text>{label}</text>
|
|
70
|
+
</view>
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export const TabBar = component<TabBarProps>(({ props }) => {
|
|
76
|
+
const nav = useTabs();
|
|
77
|
+
return () => {
|
|
78
|
+
// Reading `nav.tabs` and `nav.active` here ties this render to both
|
|
79
|
+
// the registration list and the active signal — switching active or
|
|
80
|
+
// adding/removing a `<Tabs.Screen>` updates the bar reactively.
|
|
81
|
+
const tabs = nav.tabs;
|
|
82
|
+
const active = nav.active;
|
|
83
|
+
const renderer = props.renderTab;
|
|
84
|
+
return (
|
|
85
|
+
<view accessibility-element={false}>
|
|
86
|
+
{tabs.map((info) => {
|
|
87
|
+
const isActive = info.name === active;
|
|
88
|
+
const onPress = () => nav.setActive(info.name);
|
|
89
|
+
if (renderer) {
|
|
90
|
+
return renderer(info, { active: isActive, onPress });
|
|
91
|
+
}
|
|
92
|
+
return (
|
|
93
|
+
<DefaultTabButton
|
|
94
|
+
info={info}
|
|
95
|
+
active={isActive}
|
|
96
|
+
onPress={onPress}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
99
|
+
})}
|
|
100
|
+
</view>
|
|
101
|
+
);
|
|
102
|
+
};
|
|
103
|
+
});
|