@gtkx/navigation 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +174 -0
  2. package/dist/drawer/create-drawer-navigator.d.ts +30 -0
  3. package/dist/drawer/create-drawer-navigator.d.ts.map +1 -0
  4. package/dist/drawer/create-drawer-navigator.js +36 -0
  5. package/dist/drawer/create-drawer-navigator.js.map +1 -0
  6. package/dist/drawer/drawer-collapsed-context.d.ts +4 -0
  7. package/dist/drawer/drawer-collapsed-context.d.ts.map +1 -0
  8. package/dist/drawer/drawer-collapsed-context.js +4 -0
  9. package/dist/drawer/drawer-collapsed-context.js.map +1 -0
  10. package/dist/drawer/drawer-item-list.d.ts +6 -0
  11. package/dist/drawer/drawer-item-list.d.ts.map +1 -0
  12. package/dist/drawer/drawer-item-list.js +40 -0
  13. package/dist/drawer/drawer-item-list.js.map +1 -0
  14. package/dist/drawer/drawer-router.d.ts +5 -0
  15. package/dist/drawer/drawer-router.d.ts.map +1 -0
  16. package/dist/drawer/drawer-router.js +49 -0
  17. package/dist/drawer/drawer-router.js.map +1 -0
  18. package/dist/drawer/drawer-status.d.ts +4 -0
  19. package/dist/drawer/drawer-status.d.ts.map +1 -0
  20. package/dist/drawer/drawer-status.js +11 -0
  21. package/dist/drawer/drawer-status.js.map +1 -0
  22. package/dist/drawer/drawer-view.d.ts +11 -0
  23. package/dist/drawer/drawer-view.d.ts.map +1 -0
  24. package/dist/drawer/drawer-view.js +67 -0
  25. package/dist/drawer/drawer-view.js.map +1 -0
  26. package/dist/drawer/types.d.ts +80 -0
  27. package/dist/drawer/types.d.ts.map +1 -0
  28. package/dist/drawer/types.js +2 -0
  29. package/dist/drawer/types.js.map +1 -0
  30. package/dist/index.d.ts +15 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +10 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/navigation-container.d.ts +15 -0
  35. package/dist/navigation-container.d.ts.map +1 -0
  36. package/dist/navigation-container.js +11 -0
  37. package/dist/navigation-container.js.map +1 -0
  38. package/dist/shared/header-bar.d.ts +11 -0
  39. package/dist/shared/header-bar.d.ts.map +1 -0
  40. package/dist/shared/header-bar.js +11 -0
  41. package/dist/shared/header-bar.js.map +1 -0
  42. package/dist/shared/routes.d.ts +6 -0
  43. package/dist/shared/routes.d.ts.map +1 -0
  44. package/dist/shared/routes.js +16 -0
  45. package/dist/shared/routes.js.map +1 -0
  46. package/dist/shared/scene-page.d.ts +13 -0
  47. package/dist/shared/scene-page.d.ts.map +1 -0
  48. package/dist/shared/scene-page.js +5 -0
  49. package/dist/shared/scene-page.js.map +1 -0
  50. package/dist/shared/types.d.ts +16 -0
  51. package/dist/shared/types.d.ts.map +1 -0
  52. package/dist/shared/types.js +2 -0
  53. package/dist/shared/types.js.map +1 -0
  54. package/dist/shared/use-loaded-routes.d.ts +3 -0
  55. package/dist/shared/use-loaded-routes.d.ts.map +1 -0
  56. package/dist/shared/use-loaded-routes.js +11 -0
  57. package/dist/shared/use-loaded-routes.js.map +1 -0
  58. package/dist/shared/use-pop-to-top-on-blur.d.ts +10 -0
  59. package/dist/shared/use-pop-to-top-on-blur.d.ts.map +1 -0
  60. package/dist/shared/use-pop-to-top-on-blur.js +27 -0
  61. package/dist/shared/use-pop-to-top-on-blur.js.map +1 -0
  62. package/dist/split-view/create-split-view-navigator.d.ts +33 -0
  63. package/dist/split-view/create-split-view-navigator.d.ts.map +1 -0
  64. package/dist/split-view/create-split-view-navigator.js +20 -0
  65. package/dist/split-view/create-split-view-navigator.js.map +1 -0
  66. package/dist/split-view/split-view-router.d.ts +5 -0
  67. package/dist/split-view/split-view-router.d.ts.map +1 -0
  68. package/dist/split-view/split-view-router.js +42 -0
  69. package/dist/split-view/split-view-router.js.map +1 -0
  70. package/dist/split-view/split-view.d.ts +12 -0
  71. package/dist/split-view/split-view.d.ts.map +1 -0
  72. package/dist/split-view/split-view.js +62 -0
  73. package/dist/split-view/split-view.js.map +1 -0
  74. package/dist/split-view/types.d.ts +43 -0
  75. package/dist/split-view/types.d.ts.map +1 -0
  76. package/dist/split-view/types.js +2 -0
  77. package/dist/split-view/types.js.map +1 -0
  78. package/dist/stack/create-stack-navigator.d.ts +30 -0
  79. package/dist/stack/create-stack-navigator.d.ts.map +1 -0
  80. package/dist/stack/create-stack-navigator.js +16 -0
  81. package/dist/stack/create-stack-navigator.js.map +1 -0
  82. package/dist/stack/stack-pages.d.ts +11 -0
  83. package/dist/stack/stack-pages.d.ts.map +1 -0
  84. package/dist/stack/stack-pages.js +80 -0
  85. package/dist/stack/stack-pages.js.map +1 -0
  86. package/dist/stack/stack-view.d.ts +14 -0
  87. package/dist/stack/stack-view.d.ts.map +1 -0
  88. package/dist/stack/stack-view.js +101 -0
  89. package/dist/stack/stack-view.js.map +1 -0
  90. package/dist/stack/sync-navigation-stack.d.ts +7 -0
  91. package/dist/stack/sync-navigation-stack.d.ts.map +1 -0
  92. package/dist/stack/sync-navigation-stack.js +69 -0
  93. package/dist/stack/sync-navigation-stack.js.map +1 -0
  94. package/dist/stack/types.d.ts +72 -0
  95. package/dist/stack/types.d.ts.map +1 -0
  96. package/dist/stack/types.js +2 -0
  97. package/dist/stack/types.js.map +1 -0
  98. package/dist/static-navigation.d.ts +13 -0
  99. package/dist/static-navigation.d.ts.map +1 -0
  100. package/dist/static-navigation.js +10 -0
  101. package/dist/static-navigation.js.map +1 -0
  102. package/dist/tabs/create-tab-navigator.d.ts +30 -0
  103. package/dist/tabs/create-tab-navigator.d.ts.map +1 -0
  104. package/dist/tabs/create-tab-navigator.js +16 -0
  105. package/dist/tabs/create-tab-navigator.js.map +1 -0
  106. package/dist/tabs/tab-view.d.ts +11 -0
  107. package/dist/tabs/tab-view.d.ts.map +1 -0
  108. package/dist/tabs/tab-view.js +52 -0
  109. package/dist/tabs/tab-view.js.map +1 -0
  110. package/dist/tabs/types.d.ts +67 -0
  111. package/dist/tabs/types.d.ts.map +1 -0
  112. package/dist/tabs/types.js +2 -0
  113. package/dist/tabs/types.js.map +1 -0
  114. package/dist/theme.d.ts +18 -0
  115. package/dist/theme.d.ts.map +1 -0
  116. package/dist/theme.js +15 -0
  117. package/dist/theme.js.map +1 -0
  118. package/package.json +71 -0
  119. package/src/drawer/create-drawer-navigator.tsx +123 -0
  120. package/src/drawer/drawer-collapsed-context.ts +5 -0
  121. package/src/drawer/drawer-item-list.tsx +74 -0
  122. package/src/drawer/drawer-router.ts +90 -0
  123. package/src/drawer/drawer-status.ts +19 -0
  124. package/src/drawer/drawer-view.tsx +171 -0
  125. package/src/drawer/types.ts +142 -0
  126. package/src/index.ts +83 -0
  127. package/src/navigation-container.tsx +35 -0
  128. package/src/shared/header-bar.tsx +34 -0
  129. package/src/shared/routes.ts +25 -0
  130. package/src/shared/scene-page.tsx +27 -0
  131. package/src/shared/types.ts +17 -0
  132. package/src/shared/use-loaded-routes.ts +14 -0
  133. package/src/shared/use-pop-to-top-on-blur.ts +45 -0
  134. package/src/split-view/create-split-view-navigator.tsx +99 -0
  135. package/src/split-view/split-view-router.ts +73 -0
  136. package/src/split-view/split-view.tsx +137 -0
  137. package/src/split-view/types.ts +100 -0
  138. package/src/stack/create-stack-navigator.tsx +80 -0
  139. package/src/stack/stack-pages.ts +140 -0
  140. package/src/stack/stack-view.tsx +202 -0
  141. package/src/stack/sync-navigation-stack.ts +117 -0
  142. package/src/stack/types.ts +131 -0
  143. package/src/static-navigation.tsx +26 -0
  144. package/src/tabs/create-tab-navigator.tsx +69 -0
  145. package/src/tabs/tab-view.tsx +126 -0
  146. package/src/tabs/types.ts +120 -0
  147. package/src/theme.ts +31 -0
@@ -0,0 +1,100 @@
1
+ import type {
2
+ DefaultNavigatorOptions,
3
+ Descriptor,
4
+ NavigationHelpers,
5
+ NavigationProp,
6
+ ParamListBase,
7
+ RouteProp,
8
+ StackActionHelpers,
9
+ StackNavigationState,
10
+ StackRouterOptions,
11
+ } from "@react-navigation/core";
12
+ import type { ReactNode } from "react";
13
+ import type { StackNavigationEventMap, StackNavigationOptions } from "../stack/types.js";
14
+
15
+ /** Events the split view navigator emits: the stack's transition events, from the content pane. */
16
+ type SplitViewNavigationEventMap = StackNavigationEventMap;
17
+ /** Per-screen options of the split view navigator, the same ones a stack screen takes. */
18
+ type SplitViewNavigationOptions = StackNavigationOptions;
19
+
20
+ /** Navigation object of a split view screen. */
21
+ type SplitViewNavigationProp<
22
+ ParamList extends ParamListBase,
23
+ RouteName extends keyof ParamList = keyof ParamList,
24
+ NavigatorID extends string | undefined = undefined,
25
+ > = NavigationProp<
26
+ ParamList,
27
+ RouteName,
28
+ NavigatorID,
29
+ StackNavigationState<ParamList>,
30
+ SplitViewNavigationOptions,
31
+ SplitViewNavigationEventMap
32
+ > & StackActionHelpers<ParamList>;
33
+
34
+ /** Props a split view screen component receives. */
35
+ type SplitViewScreenProps<
36
+ ParamList extends ParamListBase,
37
+ RouteName extends keyof ParamList = keyof ParamList,
38
+ NavigatorID extends string | undefined = undefined,
39
+ > = {
40
+ /** Navigation object of the screen. */
41
+ navigation: SplitViewNavigationProp<ParamList, RouteName, NavigatorID>;
42
+ /** The route the screen renders. */
43
+ route: RouteProp<ParamList, RouteName>;
44
+ };
45
+
46
+ /** Navigation helpers of the split view navigator itself. */
47
+ type SplitViewNavigationHelpers = NavigationHelpers<ParamListBase, SplitViewNavigationEventMap> &
48
+ StackActionHelpers<ParamListBase>;
49
+
50
+ /** Descriptor of one split view route. */
51
+ type SplitViewDescriptor = Descriptor<
52
+ SplitViewNavigationOptions,
53
+ SplitViewNavigationProp<ParamListBase>,
54
+ RouteProp<ParamListBase>
55
+ >;
56
+
57
+ /** Descriptors of the split view routes, keyed by route key. */
58
+ type SplitViewDescriptorMap = Record<string, SplitViewDescriptor>;
59
+
60
+ /** Props of the split view navigator beyond the options every navigator shares. */
61
+ type SplitViewNavigationConfig = {
62
+ /** Whether the panes stack into one instead of sitting side by side; defaults to `false`. */
63
+ collapsed?: boolean;
64
+ /** Fills the content pane while no content route is open; defaults to nothing. */
65
+ contentPlaceholder?: ReactNode;
66
+ /** Which side the sidebar sits on; defaults to `"start"`. */
67
+ sidebarPosition?: "start" | "end";
68
+ /** Minimum sidebar width, in the unit libadwaita uses for the split view. */
69
+ minSidebarWidth?: number;
70
+ /** Maximum sidebar width, in the unit libadwaita uses for the split view. */
71
+ maxSidebarWidth?: number;
72
+ /** Preferred sidebar width as a fraction of the total width. */
73
+ sidebarWidthFraction?: number;
74
+ /** Whether pressing Escape pops the visible content page; defaults to `true`. */
75
+ popOnEscape?: boolean;
76
+ };
77
+
78
+ /** Props of the split view `Navigator` component. */
79
+ type SplitViewNavigatorProps = DefaultNavigatorOptions<
80
+ ParamListBase,
81
+ string | undefined,
82
+ StackNavigationState<ParamListBase>,
83
+ SplitViewNavigationOptions,
84
+ SplitViewNavigationEventMap,
85
+ SplitViewNavigationProp<ParamListBase>
86
+ > &
87
+ StackRouterOptions &
88
+ SplitViewNavigationConfig;
89
+
90
+ export type {
91
+ SplitViewDescriptor,
92
+ SplitViewDescriptorMap,
93
+ SplitViewNavigationConfig,
94
+ SplitViewNavigationEventMap,
95
+ SplitViewNavigationHelpers,
96
+ SplitViewNavigationOptions,
97
+ SplitViewNavigationProp,
98
+ SplitViewNavigatorProps,
99
+ SplitViewScreenProps,
100
+ };
@@ -0,0 +1,80 @@
1
+ import type {
2
+ NavigatorTypeBagBase,
3
+ ParamListBase,
4
+ StackActionHelpers,
5
+ StackNavigationState,
6
+ StackRouterOptions,
7
+ StaticConfig,
8
+ StaticScreenFactory,
9
+ TypedNavigator,
10
+ } from "@react-navigation/core";
11
+ import type { ReactNode } from "react";
12
+ import { createNavigatorFactory, createScreenFactory, StackRouter, useNavigationBuilder } from "@react-navigation/core";
13
+ import type {
14
+ StackNavigationEventMap,
15
+ StackNavigationOptions,
16
+ StackNavigationProp,
17
+ StackNavigatorProps,
18
+ } from "./types.js";
19
+ import { StackView } from "./stack-view.js";
20
+
21
+ /** Type bag describing the stack navigator to React Navigation's typed APIs. */
22
+ type StackTypeBag<
23
+ ParamList extends ParamListBase = ParamListBase,
24
+ NavigatorID extends string | undefined = string | undefined,
25
+ > = {
26
+ /** Route names and their params. */
27
+ ParamList: ParamList;
28
+ /** Optional navigator id. */
29
+ NavigatorID: NavigatorID;
30
+ /** Shape of the navigator state. */
31
+ State: StackNavigationState<ParamList>;
32
+ /** Per-screen options. */
33
+ ScreenOptions: StackNavigationOptions;
34
+ /** Events the navigator emits. */
35
+ EventMap: StackNavigationEventMap;
36
+ /** Navigation object per route. */
37
+ NavigationList: {
38
+ [RouteName in keyof ParamList]: StackNavigationProp<ParamList, RouteName, NavigatorID>;
39
+ };
40
+ /** The navigator component. */
41
+ Navigator: typeof StackNavigator;
42
+ };
43
+
44
+ /** Declares a typed screen of a stack navigator for the static configuration API. */
45
+ const createStackScreen: StaticScreenFactory<StackTypeBag> = createScreenFactory<StackTypeBag>();
46
+
47
+ /** Renders the screens of a {@link createStackNavigator} as pages of an `AdwNavigationView`. */
48
+ function StackNavigator({ popOnEscape, ...options }: StackNavigatorProps): ReactNode {
49
+ const { state, describe, descriptors, navigation, NavigationContent } = useNavigationBuilder<
50
+ StackNavigationState<ParamListBase>,
51
+ StackRouterOptions,
52
+ StackActionHelpers<ParamListBase>,
53
+ StackNavigationOptions,
54
+ StackNavigationEventMap
55
+ >(StackRouter, options);
56
+
57
+ return (
58
+ <NavigationContent>
59
+ <StackView
60
+ popOnEscape={popOnEscape}
61
+ state={state}
62
+ navigation={navigation}
63
+ descriptors={descriptors}
64
+ describe={describe}
65
+ />
66
+ </NavigationContent>
67
+ );
68
+ }
69
+
70
+ /** Creates a stack navigator rendered with `AdwNavigationView`: pushes and pops animate like native Adwaita pages. */
71
+ function createStackNavigator<
72
+ const ParamList extends ParamListBase,
73
+ const NavigatorID extends string | undefined = string | undefined,
74
+ const TypeBag extends NavigatorTypeBagBase = StackTypeBag<ParamList, NavigatorID>,
75
+ const Config extends StaticConfig<TypeBag> | undefined = undefined,
76
+ >(config?: Config): TypedNavigator<TypeBag, Config> {
77
+ return createNavigatorFactory(StackNavigator)(config) as TypedNavigator<TypeBag, Config>;
78
+ }
79
+
80
+ export { createStackNavigator, createStackScreen, StackNavigator, type StackTypeBag };
@@ -0,0 +1,140 @@
1
+ import type { ParamListBase, RouteProp, StackNavigationState } from "@react-navigation/core";
2
+ import { useCallback, useState } from "react";
3
+ import type { StackDescriptor, StackDescriptorMap } from "./types.js";
4
+
5
+ type StackState = StackNavigationState<ParamListBase>;
6
+ type Describe = (route: RouteProp<ParamListBase>, isPlaceholder: boolean) => StackDescriptor;
7
+
8
+ type PageTracking = {
9
+ order: readonly string[];
10
+ focusedKey: string | undefined;
11
+ descriptors: StackDescriptorMap;
12
+ closing: StackDescriptorMap;
13
+ };
14
+
15
+ type StackPages = {
16
+ pages: readonly StackDescriptor[];
17
+ release: (key: string) => void;
18
+ };
19
+
20
+ const getFocusedKey = (state: StackState, offset: number): string | undefined =>
21
+ state.index < offset ? undefined : state.routes[state.index]?.key;
22
+
23
+ const mountedKeys = (state: StackState, offset: number): string[] => [
24
+ ...state.routes.slice(offset).map((route) => route.key),
25
+ ...state.preloadedRoutes.map((route) => route.key),
26
+ ];
27
+
28
+ const neededKeys = (state: StackState, closing: StackDescriptorMap, offset: number): string[] => [
29
+ ...mountedKeys(state, offset),
30
+ ...Object.keys(closing),
31
+ ];
32
+
33
+ const hasSameMembers = (left: readonly string[], right: readonly string[]): boolean =>
34
+ left.length === right.length && left.every((key) => right.includes(key));
35
+
36
+ const hasRefreshedFocus = (tracking: PageTracking, descriptors: StackDescriptorMap): boolean => {
37
+ const { focusedKey } = tracking;
38
+
39
+ if (focusedKey === undefined) {
40
+ return false;
41
+ }
42
+
43
+ const next = descriptors[focusedKey];
44
+
45
+ return next !== undefined && tracking.descriptors[focusedKey]?.route !== next.route;
46
+ };
47
+
48
+ const appendOrder = (previous: readonly string[], needed: readonly string[]): string[] => [
49
+ ...previous.filter((key) => needed.includes(key)),
50
+ ...needed.filter((key) => !previous.includes(key)),
51
+ ];
52
+
53
+ const withoutKeys = (map: StackDescriptorMap, keys: readonly string[]): StackDescriptorMap =>
54
+ Object.fromEntries(Object.entries(map).filter(([key]) => !keys.includes(key)));
55
+
56
+ const nextClosing = (tracking: PageTracking, state: StackState, offset: number): StackDescriptorMap => {
57
+ const mounted = mountedKeys(state, offset);
58
+ const kept = withoutKeys(tracking.closing, mounted);
59
+ const { focusedKey } = tracking;
60
+
61
+ if (focusedKey === undefined || mounted.includes(focusedKey)) {
62
+ return kept;
63
+ }
64
+
65
+ const descriptor = tracking.descriptors[focusedKey];
66
+
67
+ return descriptor === undefined ? kept : { ...kept, [focusedKey]: descriptor };
68
+ };
69
+
70
+ const advance = (
71
+ tracking: PageTracking,
72
+ state: StackState,
73
+ descriptors: StackDescriptorMap,
74
+ offset: number,
75
+ ): PageTracking => {
76
+ const closing = nextClosing(tracking, state, offset);
77
+
78
+ return {
79
+ order: appendOrder(tracking.order, neededKeys(state, closing, offset)),
80
+ focusedKey: getFocusedKey(state, offset),
81
+ descriptors,
82
+ closing,
83
+ };
84
+ };
85
+
86
+ const initialTracking = (state: StackState, descriptors: StackDescriptorMap, offset: number): PageTracking => ({
87
+ order: neededKeys(state, {}, offset),
88
+ focusedKey: getFocusedKey(state, offset),
89
+ descriptors,
90
+ closing: {},
91
+ });
92
+
93
+ const resolvePage = (
94
+ key: string,
95
+ sources: { state: StackState; descriptors: StackDescriptorMap; closing: StackDescriptorMap },
96
+ describe: Describe,
97
+ ): StackDescriptor | undefined => {
98
+ const preloaded = sources.state.preloadedRoutes.find((route) => route.key === key);
99
+
100
+ if (preloaded !== undefined) {
101
+ return describe(preloaded, true);
102
+ }
103
+
104
+ return sources.descriptors[key] ?? sources.closing[key];
105
+ };
106
+
107
+ const useStackPages = (
108
+ state: StackState,
109
+ descriptors: StackDescriptorMap,
110
+ describe: Describe,
111
+ offset: number,
112
+ ): StackPages => {
113
+ const [tracking, setTracking] = useState<PageTracking>(() => initialTracking(state, descriptors, offset));
114
+
115
+ if (!hasSameMembers(tracking.order, neededKeys(state, tracking.closing, offset)) ||
116
+ tracking.focusedKey !== getFocusedKey(state, offset) ||
117
+ hasRefreshedFocus(tracking, descriptors)) {
118
+ setTracking(advance(tracking, state, descriptors, offset));
119
+ }
120
+
121
+ const release = useCallback((key: string) => {
122
+ setTracking((current) => (current.closing[key] === undefined
123
+ ? current
124
+ : {
125
+ ...current,
126
+ order: current.order.filter((entry) => entry !== key),
127
+ closing: withoutKeys(current.closing, [key]),
128
+ }));
129
+ }, []);
130
+
131
+ const sources = { state, descriptors, closing: tracking.closing };
132
+
133
+ const pages = tracking.order
134
+ .map((key) => resolvePage(key, sources, describe))
135
+ .filter((page) => page !== undefined);
136
+
137
+ return { pages, release };
138
+ };
139
+
140
+ export { type Describe, useStackPages };
@@ -0,0 +1,202 @@
1
+ import type * as Adw from "@gtkx/gi/adw";
2
+ import type { ParamListBase, StackNavigationState } from "@react-navigation/core";
3
+ import type { ReactNode, RefObject } from "react";
4
+ import { AdwNavigationPage, AdwNavigationView, AdwToolbarView } from "@gtkx/jsx/adw";
5
+ import { useCallback, useEffect, useRef } from "react";
6
+ import type { Describe } from "./stack-pages.js";
7
+ import type { OptionsByKey } from "./sync-navigation-stack.js";
8
+ import type {
9
+ StackDescriptor,
10
+ StackDescriptorMap,
11
+ StackNavigationConfig,
12
+ StackNavigationEventMap,
13
+ StackNavigationHelpers,
14
+ StackNavigationOptions,
15
+ } from "./types.js";
16
+ import { HeaderBar } from "../shared/header-bar.js";
17
+ import { useStackPages } from "./stack-pages.js";
18
+ import { reconcilePoppedStack, syncNavigationStack } from "./sync-navigation-stack.js";
19
+
20
+ type ViewRef = RefObject<Adw.NavigationView | null>;
21
+ type MutableOptions = Record<string, StackNavigationOptions>;
22
+
23
+ type StackViewProps = StackNavigationConfig & {
24
+ state: StackNavigationState<ParamListBase>;
25
+ navigation: StackNavigationHelpers;
26
+ descriptors: StackDescriptorMap;
27
+ describe: Describe;
28
+ offset?: number;
29
+ };
30
+
31
+ type StackPageProps = {
32
+ descriptor: StackDescriptor;
33
+ previous?: StackDescriptor;
34
+ navigation: StackNavigationHelpers;
35
+ onHidden: (key: string) => void;
36
+ };
37
+
38
+ type TransitionEvent = keyof StackNavigationEventMap;
39
+
40
+ const previousDescriptor = (
41
+ state: StackNavigationState<ParamListBase>,
42
+ descriptors: StackDescriptorMap,
43
+ key: string,
44
+ offset: number,
45
+ ): StackDescriptor | undefined => {
46
+ const index = state.routes.findIndex((route) => route.key === key);
47
+ const previousKey = index > offset ? state.routes[index - 1]?.key : undefined;
48
+
49
+ return previousKey === undefined ? undefined : descriptors[previousKey];
50
+ };
51
+
52
+ const useLatest = <T,>(value: T): RefObject<T> => {
53
+ const ref = useRef(value);
54
+
55
+ useEffect(() => {
56
+ ref.current = value;
57
+ }, [value]);
58
+
59
+ return ref;
60
+ };
61
+
62
+ const useTrackedOptions = (descriptors: StackDescriptorMap): RefObject<MutableOptions> => {
63
+ const optionsRef = useRef<MutableOptions>({});
64
+
65
+ useEffect(() => {
66
+ for (const [key, descriptor] of Object.entries(descriptors)) {
67
+ optionsRef.current[key] = descriptor.options;
68
+ }
69
+ }, [descriptors]);
70
+
71
+ return optionsRef;
72
+ };
73
+
74
+ const useStackSync = (
75
+ viewRef: ViewRef,
76
+ state: StackNavigationState<ParamListBase>,
77
+ options: RefObject<OptionsByKey>,
78
+ offset: number,
79
+ ): void => {
80
+ const isInitialRef = useRef(true);
81
+
82
+ useEffect(() => {
83
+ const view = viewRef.current;
84
+
85
+ if (view === null) {
86
+ return;
87
+ }
88
+
89
+ const desired = state.routes.slice(offset).map((route) => route.key);
90
+ syncNavigationStack(view, desired, options.current, isInitialRef.current);
91
+ isInitialRef.current = false;
92
+ }, [viewRef, state, options, offset]);
93
+ };
94
+
95
+ const usePoppedHandler = (
96
+ viewRef: ViewRef,
97
+ navigation: StackNavigationHelpers,
98
+ options: RefObject<OptionsByKey>,
99
+ offset: number,
100
+ ): (() => void) => {
101
+ const navigationRef = useLatest(navigation);
102
+ const isScheduledRef = useRef(false);
103
+
104
+ return useCallback(() => {
105
+ if (isScheduledRef.current) {
106
+ return;
107
+ }
108
+
109
+ isScheduledRef.current = true;
110
+
111
+ queueMicrotask(() => {
112
+ isScheduledRef.current = false;
113
+ const view = viewRef.current;
114
+
115
+ if (view !== null) {
116
+ reconcilePoppedStack(view, navigationRef.current, options.current, offset);
117
+ }
118
+ });
119
+ }, [viewRef, navigationRef, options, offset]);
120
+ };
121
+
122
+ const StackPageContent = ({ descriptor, previous }: Omit<StackPageProps, "navigation" | "onHidden">): ReactNode => {
123
+ const { route, options, navigation } = descriptor;
124
+
125
+ if (options.headerShown === false) {
126
+ return descriptor.render();
127
+ }
128
+
129
+ const back = previous === undefined ? undefined : { title: previous.options.title ?? previous.route.name };
130
+
131
+ const topBar = options.header === undefined
132
+ ? <HeaderBar options={options} showBackButton={options.headerBackVisible ?? true} />
133
+ : <>{options.header({ route, navigation, options, back })}</>;
134
+
135
+ return <AdwToolbarView topBar={topBar}>{descriptor.render()}</AdwToolbarView>;
136
+ };
137
+
138
+ const StackPage = ({ descriptor, previous, navigation, onHidden }: StackPageProps): ReactNode => {
139
+ const { route, options } = descriptor;
140
+ const pageRef = useRef<Adw.NavigationPage | null>(null);
141
+
142
+ const emit = (type: TransitionEvent, isClosing: boolean): void => {
143
+ navigation.emit({ type, data: { closing: isClosing }, target: route.key });
144
+ };
145
+
146
+ useEffect(() => {
147
+ const page = pageRef.current;
148
+
149
+ if (page !== null && !page.getMapped()) {
150
+ onHidden(route.key);
151
+ }
152
+ });
153
+
154
+ return (
155
+ <AdwNavigationPage
156
+ ref={pageRef}
157
+ tag={route.key}
158
+ title={options.title ?? route.name}
159
+ canPop={options.canPop ?? true}
160
+ onShowing={() => {
161
+ emit("transitionStart", false);
162
+ }}
163
+ onHiding={() => {
164
+ emit("transitionStart", true);
165
+ }}
166
+ onShown={() => {
167
+ emit("transitionEnd", false);
168
+ }}
169
+ onHidden={() => {
170
+ emit("transitionEnd", true);
171
+ onHidden(route.key);
172
+ }}
173
+ >
174
+ <StackPageContent descriptor={descriptor} previous={previous} />
175
+ </AdwNavigationPage>
176
+ );
177
+ };
178
+
179
+ const StackView = (props: StackViewProps): ReactNode => {
180
+ const { state, navigation, descriptors, describe, popOnEscape, offset = 0 } = props;
181
+ const viewRef = useRef<Adw.NavigationView | null>(null);
182
+ const options = useTrackedOptions(descriptors);
183
+ const { pages, release } = useStackPages(state, descriptors, describe, offset);
184
+ const onPopped = usePoppedHandler(viewRef, navigation, options, offset);
185
+ useStackSync(viewRef, state, options, offset);
186
+
187
+ return (
188
+ <AdwNavigationView ref={viewRef} popOnEscape={popOnEscape ?? true} onPopped={onPopped}>
189
+ {pages.map((descriptor) => (
190
+ <StackPage
191
+ key={descriptor.route.key}
192
+ descriptor={descriptor}
193
+ previous={previousDescriptor(state, descriptors, descriptor.route.key, offset)}
194
+ navigation={navigation}
195
+ onHidden={release}
196
+ />
197
+ ))}
198
+ </AdwNavigationView>
199
+ );
200
+ };
201
+
202
+ export { StackView };
@@ -0,0 +1,117 @@
1
+ import * as Adw from "@gtkx/gi/adw";
2
+ import { StackActions } from "@react-navigation/core";
3
+ import type { StackNavigationHelpers, StackNavigationOptions } from "./types.js";
4
+
5
+ type OptionsByKey = Readonly<Record<string, StackNavigationOptions | undefined>>;
6
+
7
+ const readNavigationStack = (view: Adw.NavigationView): string[] => {
8
+ const model = view.getNavigationStack();
9
+ const count = model.getNItems();
10
+ const tags: string[] = [];
11
+
12
+ for (let index = 0; index < count; index += 1) {
13
+ const page = model.getItem(index);
14
+ const tag = page instanceof Adw.NavigationPage ? page.getTag() : null;
15
+
16
+ if (tag !== null) {
17
+ tags.push(tag);
18
+ }
19
+ }
20
+
21
+ return tags;
22
+ };
23
+
24
+ const isSameStack = (left: readonly string[], right: readonly string[]): boolean =>
25
+ left.length === right.length && left.every((tag, index) => right[index] === tag);
26
+
27
+ const isStrictPrefix = (prefix: readonly string[], list: readonly string[]): boolean =>
28
+ prefix.length < list.length && prefix.every((tag, index) => list[index] === tag);
29
+
30
+ const isAnimated = (options: OptionsByKey, key: string | undefined): boolean =>
31
+ key === undefined || options[key]?.animation !== "none";
32
+
33
+ const pushOnto = (
34
+ view: Adw.NavigationView,
35
+ current: readonly string[],
36
+ desired: readonly string[],
37
+ options: OptionsByKey,
38
+ ): void => {
39
+ const base = desired.slice(0, -1);
40
+ const last = desired.at(-1);
41
+
42
+ if (!isSameStack(base, current)) {
43
+ view.replaceWithTags(base);
44
+ }
45
+
46
+ if (last !== undefined) {
47
+ view.setAnimateTransitions(isAnimated(options, last));
48
+ view.pushByTag(last);
49
+ }
50
+ };
51
+
52
+ const applyStackChange = (
53
+ view: Adw.NavigationView,
54
+ current: readonly string[],
55
+ desired: readonly string[],
56
+ options: OptionsByKey,
57
+ ): void => {
58
+ const last = desired.at(-1);
59
+
60
+ if (last !== undefined && isStrictPrefix(desired, current)) {
61
+ view.setAnimateTransitions(isAnimated(options, current.at(-1)));
62
+ view.popToTag(last);
63
+ } else if (isStrictPrefix(current, desired)) {
64
+ pushOnto(view, current, desired, options);
65
+ } else {
66
+ view.replaceWithTags([...desired]);
67
+ }
68
+ };
69
+
70
+ const syncNavigationStack = (
71
+ view: Adw.NavigationView,
72
+ desired: readonly string[],
73
+ options: OptionsByKey,
74
+ isInitial: boolean,
75
+ ): void => {
76
+ const current = readNavigationStack(view);
77
+
78
+ if (!isSameStack(current, desired)) {
79
+ if (isInitial) {
80
+ view.replaceWithTags([...desired]);
81
+ } else {
82
+ applyStackChange(view, current, desired, options);
83
+ }
84
+ }
85
+
86
+ view.setAnimateTransitions(isAnimated(options, desired.at(-1)));
87
+ };
88
+
89
+ const routeKeys = (state: { routes: readonly { key: string }[] }, offset: number): string[] =>
90
+ state.routes.slice(offset).map((route) => route.key);
91
+
92
+ const popToActualStack = (
93
+ navigation: StackNavigationHelpers,
94
+ actual: readonly string[],
95
+ desired: readonly string[],
96
+ ): void => {
97
+ const state = navigation.getState();
98
+ navigation.dispatch({ ...StackActions.pop(desired.length - actual.length), target: state.key });
99
+ };
100
+
101
+ const reconcilePoppedStack = (
102
+ view: Adw.NavigationView,
103
+ navigation: StackNavigationHelpers,
104
+ options: OptionsByKey,
105
+ offset: number,
106
+ ): void => {
107
+ const actual = readNavigationStack(view);
108
+ const desired = routeKeys(navigation.getState(), offset);
109
+
110
+ if (isStrictPrefix(actual, desired)) {
111
+ popToActualStack(navigation, actual, desired);
112
+ }
113
+
114
+ syncNavigationStack(view, routeKeys(navigation.getState(), offset), options, false);
115
+ };
116
+
117
+ export { type OptionsByKey, reconcilePoppedStack, syncNavigationStack };