@vkma.dev/router 0.0.0-stage → 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.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/dist/core/bridge-like.d.ts +25 -0
  3. package/dist/core/bridge-like.d.ts.map +1 -0
  4. package/dist/core/bridge-like.js +13 -0
  5. package/dist/core/bridge-like.js.map +1 -0
  6. package/dist/core/bridge-sync.d.ts +37 -0
  7. package/dist/core/bridge-sync.d.ts.map +1 -0
  8. package/dist/core/bridge-sync.js +58 -0
  9. package/dist/core/bridge-sync.js.map +1 -0
  10. package/dist/core/hash-params.d.ts +40 -0
  11. package/dist/core/hash-params.d.ts.map +1 -0
  12. package/dist/core/hash-params.js +60 -0
  13. package/dist/core/hash-params.js.map +1 -0
  14. package/dist/core/loose-route.js +9 -0
  15. package/dist/core/loose-route.js.map +1 -0
  16. package/dist/core/panels-stack.d.ts +28 -0
  17. package/dist/core/panels-stack.d.ts.map +1 -0
  18. package/dist/core/panels-stack.js +60 -0
  19. package/dist/core/panels-stack.js.map +1 -0
  20. package/dist/core/state-keys.d.ts +10 -0
  21. package/dist/core/state-keys.d.ts.map +1 -0
  22. package/dist/core/state-keys.js +21 -0
  23. package/dist/core/state-keys.js.map +1 -0
  24. package/dist/core/swipe-back.d.ts +25 -0
  25. package/dist/core/swipe-back.d.ts.map +1 -0
  26. package/dist/core/swipe-back.js +53 -0
  27. package/dist/core/swipe-back.js.map +1 -0
  28. package/dist/core/vk-history.d.ts +41 -0
  29. package/dist/core/vk-history.d.ts.map +1 -0
  30. package/dist/core/vk-history.js +115 -0
  31. package/dist/core/vk-history.js.map +1 -0
  32. package/dist/index.d.ts +8 -0
  33. package/dist/index.js +8 -0
  34. package/dist/preset/app.d.ts +35 -0
  35. package/dist/preset/app.d.ts.map +1 -0
  36. package/dist/preset/app.js +134 -0
  37. package/dist/preset/app.js.map +1 -0
  38. package/dist/preset/builders.d.ts +209 -0
  39. package/dist/preset/builders.d.ts.map +1 -0
  40. package/dist/preset/builders.js +209 -0
  41. package/dist/preset/builders.js.map +1 -0
  42. package/dist/preset/define-panel.d.ts +77 -0
  43. package/dist/preset/define-panel.d.ts.map +1 -0
  44. package/dist/preset/define-panel.js +42 -0
  45. package/dist/preset/define-panel.js.map +1 -0
  46. package/dist/preset/define-view.d.ts +42 -0
  47. package/dist/preset/define-view.d.ts.map +1 -0
  48. package/dist/preset/define-view.js +43 -0
  49. package/dist/preset/define-view.js.map +1 -0
  50. package/dist/preset/inflate.d.ts +17 -0
  51. package/dist/preset/inflate.d.ts.map +1 -0
  52. package/dist/preset/inflate.js +40 -0
  53. package/dist/preset/inflate.js.map +1 -0
  54. package/dist/preset/navigator.d.ts +75 -0
  55. package/dist/preset/navigator.d.ts.map +1 -0
  56. package/dist/preset/navigator.js +116 -0
  57. package/dist/preset/navigator.js.map +1 -0
  58. package/dist/preset.d.ts +17 -0
  59. package/dist/preset.js +16 -0
  60. package/dist/react/hooks.d.ts +69 -0
  61. package/dist/react/hooks.d.ts.map +1 -0
  62. package/dist/react/hooks.js +101 -0
  63. package/dist/react/hooks.js.map +1 -0
  64. package/dist/react.d.ts +2 -0
  65. package/dist/react.js +1 -0
  66. package/dist/vkui/choreography.js +59 -0
  67. package/dist/vkui/choreography.js.map +1 -0
  68. package/dist/vkui/modals.d.ts +49 -0
  69. package/dist/vkui/modals.d.ts.map +1 -0
  70. package/dist/vkui/modals.js +116 -0
  71. package/dist/vkui/modals.js.map +1 -0
  72. package/dist/vkui/not-found.d.ts +9 -0
  73. package/dist/vkui/not-found.d.ts.map +1 -0
  74. package/dist/vkui/not-found.js +23 -0
  75. package/dist/vkui/not-found.js.map +1 -0
  76. package/dist/vkui/popouts.d.ts +34 -0
  77. package/dist/vkui/popouts.d.ts.map +1 -0
  78. package/dist/vkui/popouts.js +69 -0
  79. package/dist/vkui/popouts.js.map +1 -0
  80. package/dist/vkui/vk-view.d.ts +26 -0
  81. package/dist/vkui/vk-view.d.ts.map +1 -0
  82. package/dist/vkui/vk-view.js +81 -0
  83. package/dist/vkui/vk-view.js.map +1 -0
  84. package/dist/vkui.d.ts +5 -0
  85. package/dist/vkui.js +5 -0
  86. package/package.json +70 -3
  87. package/src/core/bridge-like.ts +25 -0
  88. package/src/core/bridge-sync.ts +95 -0
  89. package/src/core/hash-params.ts +73 -0
  90. package/src/core/loose-route.ts +17 -0
  91. package/src/core/panels-stack.ts +85 -0
  92. package/src/core/state-keys.ts +39 -0
  93. package/src/core/swipe-back.ts +80 -0
  94. package/src/core/vk-history.ts +146 -0
  95. package/src/index.ts +30 -0
  96. package/src/preset/app.tsx +192 -0
  97. package/src/preset/builders.tsx +566 -0
  98. package/src/preset/define-panel.ts +109 -0
  99. package/src/preset/define-view.ts +73 -0
  100. package/src/preset/inflate.ts +57 -0
  101. package/src/preset/navigator.ts +250 -0
  102. package/src/preset.ts +51 -0
  103. package/src/react/hooks.ts +168 -0
  104. package/src/react.ts +11 -0
  105. package/src/vkui/choreography.ts +105 -0
  106. package/src/vkui/modals.tsx +216 -0
  107. package/src/vkui/not-found.tsx +21 -0
  108. package/src/vkui/popouts.tsx +106 -0
  109. package/src/vkui/vk-view.tsx +147 -0
  110. package/src/vkui.ts +17 -0
  111. package/README.md +0 -3
@@ -0,0 +1,73 @@
1
+ import type { LooseRouteOptions, PanelLazy, PanelOptions } from "./builders.ts";
2
+ import { definePanel, type PanelDefinition } from "./define-panel.ts";
3
+
4
+ /** Brand distinguishing a {@link ViewDefinition} from raw view options. */
5
+ export const VIEW_DEFINITION = "__vkmaView" as const;
6
+
7
+ /**
8
+ * A view hub: the ONE place a view key is written. Created by
9
+ * {@link defineView}; its `panel()` method is the view-scoped
10
+ * `definePanel` — it creates the definition and registers it into the
11
+ * view's registry in call order (the order is what VKUI View children get,
12
+ * i.e. the push/pop animation direction).
13
+ */
14
+ export interface ViewDefinition<V extends string = string> {
15
+ readonly [VIEW_DEFINITION]: true;
16
+ /** The view (story) namespace and route id segment (`view:${view}`). */
17
+ readonly view: V;
18
+ /** Definitions registered by `panel()`, keyed by panel path. */
19
+ readonly panels: Record<string, PanelDefinition>;
20
+ /** Optional view-level route options passthrough (the view is a pathless layout). */
21
+ readonly options?: LooseRouteOptions;
22
+ }
23
+
24
+ export interface ViewScope<V extends string = string> {
25
+ /** View-scoped `definePanel` — the view argument is pre-bound. */
26
+ panel<const P extends string, O extends PanelOptions = PanelOptions>(
27
+ path: P,
28
+ options: O,
29
+ lazy?: PanelLazy,
30
+ ): PanelDefinition<V, P, O>;
31
+ }
32
+
33
+ /**
34
+ * Declare a view hub and its panel factory:
35
+ *
36
+ * ```ts
37
+ * export const feedView = defineView("feed");
38
+ *
39
+ * export const feedPanel = feedView.panel("feed", { loader: ... }, () => import("./feed.ui"));
40
+ * export const postPanel = feedView.panel("post/$id", { ... }, () => import("./post.ui"));
41
+ * ```
42
+ *
43
+ * The view key is written exactly once; `createApp` accepts the hub
44
+ * directly (`views: [feedView, ...]`). Duplicate view keys are a dev-time
45
+ * console error — a view must not sprawl across files.
46
+ */
47
+ export function defineView<const V extends string>(
48
+ view: V,
49
+ options?: LooseRouteOptions,
50
+ ): ViewDefinition<V> & ViewScope<V> {
51
+ const usedPanelPaths = new Set<string>();
52
+ const panels: Record<string, PanelDefinition> = {};
53
+ const definition: ViewDefinition<V> = {
54
+ [VIEW_DEFINITION]: true,
55
+ view,
56
+ panels,
57
+ ...(options !== undefined ? { options } : {}),
58
+ };
59
+ const scope: ViewScope<V> = {
60
+ panel(path, panelOptions, lazy) {
61
+ if (usedPanelPaths.has(path)) {
62
+ console.error(
63
+ `[vkma-router] duplicate panel path "${path}" in view "${view}"; panels within a view must have distinct paths`,
64
+ );
65
+ }
66
+ usedPanelPaths.add(path);
67
+ const declared = definePanel(view, path, panelOptions, lazy);
68
+ panels[path] = declared;
69
+ return declared;
70
+ },
71
+ };
72
+ return { ...definition, ...scope };
73
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Inflate: a synthetic parent chain after a deep link. Deep-linked to the
3
+ * 4th panel? Back should walk the natural flow (panel 1 → 2 → 3 → 4)
4
+ * instead of closing the app. The chain is auto-derived from the view's
5
+ * panel declaration order (declaration order IS the navigation flow — the
6
+ * same convention VKUI animations rely on), replacing the old router's
7
+ * manual `hierarchy` config.
8
+ */
9
+
10
+ import type { PanelHandle } from "../preset/builders.tsx";
11
+
12
+ /**
13
+ * Build the chain of hrefs to replay BEFORE the target location.
14
+ *
15
+ * @param orderedPanels panels of the target's view, in declaration order
16
+ * @param targetRouteId route id of the deep-linked panel
17
+ * @param params params of the deep-linked location (fill intermediate
18
+ * panels' path params; a panel whose params cannot be filled
19
+ * is skipped from the chain)
20
+ * @returns hrefs excluding the target (the target is already the current
21
+ * history entry — replay replaces the first chain entry, pushes
22
+ * the rest, then re-pushes the target href)
23
+ */
24
+ export function deriveInflateChain(
25
+ orderedPanels: PanelHandle[],
26
+ targetRouteId: string,
27
+ params: Record<string, string>,
28
+ ): string[] {
29
+ const targetIndex = orderedPanels.findIndex(panel => panel.routeId === targetRouteId);
30
+ if (targetIndex <= 0) {
31
+ return []; // first panel of the view (or unknown) — nothing to inflate
32
+ }
33
+ const chain: string[] = [];
34
+ for (const panel of orderedPanels.slice(0, targetIndex)) {
35
+ const interpolated = interpolatePath(panel.path, params);
36
+ if (interpolated !== null) {
37
+ chain.push(interpolated); // a required param cannot be derived — skip the step
38
+ }
39
+ }
40
+ return chain;
41
+ }
42
+
43
+ /**
44
+ * Fill a route path's `$param` segments from the deep link's params.
45
+ * Returns null when a required param cannot be derived (the step is
46
+ * unrepresentable as a URL and must be skipped).
47
+ */
48
+ export function interpolatePath(path: string, params: Record<string, string>): string | null {
49
+ const interpolated = path.replace(/\$([A-Za-z0-9_]+)/g, (segment, key: string) => {
50
+ const value = params[key];
51
+ return value !== undefined ? value : segment;
52
+ });
53
+ if (interpolated.includes("$")) {
54
+ return null;
55
+ }
56
+ return interpolated.startsWith("/") ? interpolated : `/${interpolated}`;
57
+ }
@@ -0,0 +1,250 @@
1
+ import {
2
+ useNavigate,
3
+ useRouter,
4
+ useRouterState,
5
+ type NavigateOptions,
6
+ } from "@tanstack/react-router";
7
+ import { looseRouteOf } from "../core/loose-route.ts";
8
+ import { MODAL_STATE_KEY } from "../core/state-keys.ts";
9
+ import { useHidePopout, useShowPopout } from "../vkui/popouts.tsx";
10
+
11
+ /**
12
+ * The parity navigator over TanStack primitives: push/replace/back/
13
+ * backToFirst/showModal/hideModal/showPopout/hidePopout. Modal→modal
14
+ * navigation REPLACES the entry (the host closes one modal before opening
15
+ * the next — there is no modal stack); back from a modal returns to its
16
+ * panel. `push`/`replace`/`showModal` accept full TanStack NavigateOptions
17
+ * — typed by the registered route tree.
18
+ */
19
+
20
+ export interface VkNavigator {
21
+ push<TParams extends Record<string, string>>(
22
+ page: PanelPage<TParams>,
23
+ params: TParams,
24
+ options?: Omit<NavigateOptions, "to" | "params">,
25
+ ): Promise<void>;
26
+ push(options: NavigateOptions): Promise<void>;
27
+ replace<TParams extends Record<string, string>>(
28
+ page: PanelPage<TParams>,
29
+ params: TParams,
30
+ options?: Omit<NavigateOptions, "to" | "params">,
31
+ ): Promise<void>;
32
+ replace(options: NavigateOptions): Promise<void>;
33
+ back(): void;
34
+ /** Pop all the way to the first history entry. */
35
+ backToFirst(): void;
36
+ /** Open a modal route; replaces the entry if another modal is open.
37
+ * `props` (serializable) land in history.state and are read by
38
+ * `useModalProps()` inside the modal component. */
39
+ showModal(options: NavigateOptions & { props?: unknown }): Promise<void>;
40
+ /** Close the current modal (back, or replace to the panel on first entry). */
41
+ hideModal(): void;
42
+ /**
43
+ * Open a global (`?modal=<id>`) modal from any panel, masking the URL so
44
+ * the visible path stays at the panel. Back closes it.
45
+ */
46
+ showGlobalModal(
47
+ id: string,
48
+ options?: { state?: Record<string, unknown>; props?: unknown },
49
+ ): Promise<void>;
50
+ showPopout(id: string, props?: Record<string, unknown>): void;
51
+ hidePopout(): void;
52
+ /**
53
+ * Block navigation while `onLeave` returns true (old router's
54
+ * `RouteNavigator.block`). The callback receives the current and the
55
+ * next location plus the action — PUSH/REPLACE as-is, back/forward/go
56
+ * mapped to POP (old-router `BlockerFunction` parity). Returns the
57
+ * unblock function.
58
+ */
59
+ block(onLeave: (args: BlockerArgs) => boolean): () => void;
60
+ }
61
+
62
+ function isModalRouteId(router: ReturnType<typeof useRouter>, routeId: string): boolean {
63
+ return looseRouteOf(router, routeId)?.options.staticData?.modal !== undefined;
64
+ }
65
+
66
+ export interface BlockerArgs {
67
+ /** The location the navigation starts from (TanStack history location). */
68
+ currentLocation: { href: string; pathname: string };
69
+ /** The location the navigation is heading to. */
70
+ nextLocation: { href: string; pathname: string };
71
+ /** PUSH/REPLACE as-is; back, forward and go are one POP (old-router parity). */
72
+ action: "PUSH" | "REPLACE" | "POP";
73
+ }
74
+
75
+ /**
76
+ * A typed page object — a panel definition (`defineView().panel(...)`) or
77
+ * a panel handle (`app.panels[...]`). The params type flows from the
78
+ * definition's phantom `types`, so `navigator.push(postPanel, { id })`
79
+ * type-checks the params (old-router `Page`/`PageWithParams` parity,
80
+ * without the `hasParams` discriminator quirk).
81
+ */
82
+ export interface PanelPage<TParams = Record<string, string>> {
83
+ path: string;
84
+ types?: { params: TParams };
85
+ }
86
+
87
+ export function useNavigator(): VkNavigator {
88
+ const router = useRouter();
89
+ const navigate = useNavigate();
90
+ const location = useRouterState({ select: state => state.location });
91
+ const modalOpen = useRouterState({
92
+ select: state => state.matches.some(match => isModalRouteId(router, match.routeId)),
93
+ });
94
+ const showPopout = useShowPopout();
95
+ const hidePopout = useHidePopout();
96
+
97
+ /** `"index"` and an optional leading slash normalize to the full path. */
98
+ const pageTo = (page: PanelPage): string =>
99
+ page.path === "index" ? "/" : `/${page.path.replace(/^\//, "")}`;
100
+
101
+ return {
102
+ push: ((
103
+ pageOrOptions: NavigateOptions | PanelPage,
104
+ params?: Record<string, string>,
105
+ rest?: Omit<NavigateOptions, "to" | "params">,
106
+ ) =>
107
+ params === undefined
108
+ ? navigate({
109
+ ...(pageOrOptions as NavigateOptions),
110
+ replace: false,
111
+ })
112
+ : navigate({
113
+ to: pageTo(pageOrOptions as PanelPage),
114
+ params,
115
+ replace: false,
116
+ ...rest,
117
+ })) as VkNavigator["push"],
118
+ replace: ((
119
+ pageOrOptions: NavigateOptions | PanelPage,
120
+ params?: Record<string, string>,
121
+ rest?: Omit<NavigateOptions, "to" | "params">,
122
+ ) =>
123
+ params === undefined
124
+ ? navigate({
125
+ ...(pageOrOptions as NavigateOptions),
126
+ replace: true,
127
+ })
128
+ : navigate({
129
+ to: pageTo(pageOrOptions as PanelPage),
130
+ params,
131
+ replace: true,
132
+ ...rest,
133
+ })) as VkNavigator["replace"],
134
+ back: () => {
135
+ router.history.back();
136
+ },
137
+ backToFirst: () => {
138
+ const index =
139
+ (location.state as { __TSR_index?: number } | undefined)?.__TSR_index ?? 0;
140
+ if (index > 0) {
141
+ router.history.go(-index);
142
+ }
143
+ },
144
+ showModal: ({ props, ...options }) => {
145
+ // nested modal: the target route is a CHILD of the currently open
146
+ // modal — PUSH (back unwinds the stack); sibling modals keep the
147
+ // old-router replace parity
148
+ let nested = false;
149
+ if (modalOpen && typeof options.to === "string") {
150
+ for (let i = router.state.matches.length - 1; i >= 0; i--) {
151
+ const matchRouteId = router.state.matches[i]?.routeId;
152
+ if (matchRouteId === undefined) {
153
+ continue;
154
+ }
155
+ const route = looseRouteOf(router, matchRouteId);
156
+ if (route?.options.staticData?.modal === undefined) {
157
+ continue;
158
+ }
159
+ nested =
160
+ route.fullPath !== undefined && options.to.startsWith(`${route.fullPath}/`);
161
+ break;
162
+ }
163
+ }
164
+ // TanStack types history state as its own updater shape — arbitrary
165
+ // serializable keys go through a cast (same as the popout state)
166
+ const withProps =
167
+ props !== undefined
168
+ ? {
169
+ state: {
170
+ ...(options.state as Record<string, unknown> | undefined),
171
+ [MODAL_STATE_KEY]: { props },
172
+ } as never,
173
+ }
174
+ : {};
175
+ return navigate({
176
+ ...options,
177
+ // a modal open never changes the panel behind it — carry the
178
+ // panel's search (?tab=…) onto the modal entry, or the covered
179
+ // panel would snap to its default search for the whole time the
180
+ // modal is open (and snap back on close). An explicit `search`
181
+ // from the caller always wins.
182
+ search: options.search ?? true,
183
+ replace: nested ? false : modalOpen,
184
+ ...withProps,
185
+ });
186
+ },
187
+ hideModal: () => {
188
+ if (router.history.canGoBack()) {
189
+ router.history.back();
190
+ return;
191
+ }
192
+ // deep-linked modal on the first entry: replace down to the panel
193
+ const parentPath = location.pathname.replace(/\/[^/]+$/, "") || "/";
194
+ void router.navigate({ href: parentPath, replace: true });
195
+ },
196
+ showPopout: (id, props) => {
197
+ showPopout(id, props);
198
+ },
199
+ hidePopout: () => {
200
+ hidePopout();
201
+ },
202
+ showGlobalModal: (id, { state, props, ...rest } = {}) => {
203
+ const { pathname, searchStr, hash } = router.state.location;
204
+ const params = new URLSearchParams(searchStr.replace(/^\?/, ""));
205
+ const already = params.get("modal") === id;
206
+ params.set("modal", id);
207
+ const href = `${pathname}?${params.toString()}${hash}`;
208
+ // the mask keeps the visible URL at the panel path while the modal
209
+ // is open (the modal param rides in history.state)
210
+ const maskParams = new URLSearchParams(params);
211
+ maskParams.delete("modal");
212
+ const maskQuery = maskParams.toString();
213
+ const maskHref = `${pathname}${maskQuery !== "" ? `?${maskQuery}` : ""}${hash}`;
214
+ const nextState =
215
+ state !== undefined || props !== undefined
216
+ ? {
217
+ state: {
218
+ ...state,
219
+ ...(props !== undefined ? { [MODAL_STATE_KEY]: { props } } : {}),
220
+ } as never,
221
+ }
222
+ : {};
223
+ return navigate({
224
+ href,
225
+ mask: { to: maskHref },
226
+ replace: already || modalOpen,
227
+ ...rest,
228
+ ...nextState,
229
+ });
230
+ },
231
+ block: onLeave => {
232
+ const unblock = router.history.block({
233
+ // args infer as TanStack's BlockerFnArgs (HistoryLocation × 2 +
234
+ // action) — assignable to our structural BlockerArgs
235
+ blockerFn: args =>
236
+ onLeave({
237
+ currentLocation: args.currentLocation,
238
+ nextLocation: args.nextLocation,
239
+ // TanStack distinguishes BACK/FORWARD/GO; the old router's
240
+ // BlockerFunction saw one POP — keep the parity
241
+ action:
242
+ args.action === "PUSH" || args.action === "REPLACE"
243
+ ? args.action
244
+ : "POP",
245
+ }),
246
+ });
247
+ return unblock;
248
+ },
249
+ };
250
+ }
package/src/preset.ts ADDED
@@ -0,0 +1,51 @@
1
+ export {
2
+ createApp,
3
+ panel,
4
+ type AppHandle,
5
+ type AppOptions,
6
+ type PanelHandle,
7
+ type PanelOptions,
8
+ type PanelHandlesOf,
9
+ type TypedAppHandle,
10
+ type ViewOptions,
11
+ } from "./preset/builders.tsx";
12
+ export { AppRootShell, useActiveStory, useInflate } from "./preset/app.tsx";
13
+ export { deriveInflateChain } from "./preset/inflate.ts";
14
+ export { useNavigator, type VkNavigator } from "./preset/navigator.ts";
15
+ export { definePanel, panelRouteId, type PanelDefinition } from "./preset/define-panel.ts";
16
+ export {
17
+ type ModalComponent,
18
+ type ModalDeclaration,
19
+ type ModalEntryComponent,
20
+ } from "./preset/builders.tsx";
21
+ export { defineView, type ViewDefinition } from "./preset/define-view.ts";
22
+
23
+ // the batteries a mini app almost always needs, one import away
24
+ export {
25
+ createVkHistory,
26
+ createBridgeSync,
27
+ createSwipeBackManager,
28
+ normalizeBridgeEvent,
29
+ type VkBridgeLike,
30
+ } from "./index.ts";
31
+ export {
32
+ Modals,
33
+ ModalPageSurface,
34
+ ModalCardSurface,
35
+ Popouts,
36
+ VkView,
37
+ NotFound,
38
+ useShowPopout,
39
+ useHidePopout,
40
+ usePopoutState,
41
+ type ModalEntry,
42
+ } from "./vkui.ts";
43
+ export {
44
+ usePanelParams,
45
+ usePanelSearch,
46
+ usePanelLoaderData,
47
+ useModalProps,
48
+ usePanelsHistory,
49
+ useEnableSwipeBack,
50
+ type PanelRef,
51
+ } from "./react.ts";
@@ -0,0 +1,168 @@
1
+ import { useMatch, useRouter, useRouterState } from "@tanstack/react-router";
2
+ import { useEffect, useRef } from "react";
3
+ import { looseRouteOf } from "../core/loose-route.ts";
4
+ import {
5
+ applyLocation,
6
+ initialPanelsStack,
7
+ type PanelsStackState,
8
+ panelsHistoryOf,
9
+ } from "../core/panels-stack.ts";
10
+ import { readModalState } from "../core/state-keys.ts";
11
+ import { setSwipeBackOverride } from "../core/swipe-back.ts";
12
+
13
+ /**
14
+ * The departing-panel freeze (spike 1): raw TanStack data hooks throw
15
+ * `Invariant failed: Could not find an active match` when the panel's
16
+ * route stops matching while VKUI still keeps the panel mounted during the
17
+ * back animation — the error flies past the router's error boundary and
18
+ * remounts the whole tree. These hooks never throw and retain the last
19
+ * resolved value while the panel is mounted but unmatched.
20
+ *
21
+ * Pass the TYPED panel handle (`app.panels["feed/post/$id"]`) — params /
22
+ * search / loaderData are derived from the panel's declared options
23
+ * (validateSearch return, loader return, $param path segments). A plain
24
+ * route-id string also works, with loose types.
25
+ */
26
+
27
+ /**
28
+ * Structural twin of the preset's `PanelHandle` — the phantom `types`
29
+ * member carries the panel's data types without coupling this layer to
30
+ * the preset.
31
+ */
32
+ export interface PanelRef<
33
+ TParams = Record<string, string>,
34
+ TSearch = Record<string, unknown>,
35
+ TLoaderData = unknown,
36
+ > {
37
+ readonly routeId: string;
38
+ readonly types?: { params: TParams; search: TSearch; loaderData: TLoaderData };
39
+ }
40
+
41
+ export function useLastWhileUndefined<T>(value: T | undefined): T | undefined {
42
+ const ref = useRef(value);
43
+ if (value !== undefined) {
44
+ ref.current = value;
45
+ }
46
+ return value === undefined ? ref.current : value;
47
+ }
48
+
49
+ /** Shared body of the panel data hooks — they differ only in the match field. */
50
+ function usePanelMatchField(
51
+ refOrFrom: PanelRef | string,
52
+ field: "params" | "search" | "loaderData",
53
+ ): unknown {
54
+ const routeId = typeof refOrFrom === "string" ? refOrFrom : refOrFrom.routeId;
55
+ const match = useMatch({ from: routeId, shouldThrow: false } as never);
56
+ return useLastWhileUndefined(
57
+ (match as unknown as Record<string, unknown> | undefined)?.[field],
58
+ );
59
+ }
60
+
61
+ export function usePanelParams<P extends Record<string, string>>(
62
+ ref: PanelRef<P | undefined, any, any>,
63
+ ): P;
64
+ export function usePanelParams(from: string): Record<string, string>;
65
+ export function usePanelParams(refOrFrom: PanelRef | string): unknown {
66
+ return usePanelMatchField(refOrFrom, "params") ?? {};
67
+ }
68
+
69
+ export function usePanelSearch<S>(ref: PanelRef<any, S | undefined, any>): S | undefined;
70
+ export function usePanelSearch(from: string): Record<string, unknown>;
71
+ export function usePanelSearch(refOrFrom: PanelRef | string): unknown {
72
+ return usePanelMatchField(refOrFrom, "search");
73
+ }
74
+
75
+ export function usePanelLoaderData<D>(ref: PanelRef<any, any, D | undefined>): D | undefined;
76
+ export function usePanelLoaderData(from: string): unknown;
77
+ export function usePanelLoaderData(refOrFrom: PanelRef | string): unknown {
78
+ return usePanelMatchField(refOrFrom, "loaderData");
79
+ }
80
+
81
+ /**
82
+ * Runtime props of the open modal — `showModal({ to, params, props })` /
83
+ * `showGlobalModal(id, { props })` stash them in `history.state` (so they
84
+ * survive reload and back/forward) and the modal entry component reads them
85
+ * here. Serializable values only (structured clone); same freeze semantics
86
+ * as the panel hooks — the departing modal keeps its last props while it
87
+ * animates out.
88
+ */
89
+ export function useModalProps<T = unknown>(): T | undefined {
90
+ const modal = useRouterState({ select: state => readModalState(state.location.state) });
91
+ return useLastWhileUndefined(modal?.props) as T | undefined;
92
+ }
93
+
94
+ /**
95
+ * Mirror of the history stack in panel ids, driven by `__TSR_index`.
96
+ * Applies only when the router is idle — between the location commit and
97
+ * the match resolution there is an intermediate render with a stale
98
+ * activePanel (spike 1: `[home, home]` flash, self-healed by the
99
+ * replace-on-same-index rule).
100
+ */
101
+ export function usePanelsStack(activePanel: string): PanelsStackState {
102
+ const snapshot = useRouterState({
103
+ select: state => ({
104
+ status: state.status,
105
+ index:
106
+ (state.location.state as { __TSR_index?: number } | undefined)?.__TSR_index ?? null,
107
+ }),
108
+ });
109
+ const ref = useRef<PanelsStackState>(initialPanelsStack());
110
+
111
+ if (snapshot.status === "idle") {
112
+ ref.current = applyLocation(ref.current, snapshot.index, activePanel);
113
+ }
114
+
115
+ return ref.current;
116
+ }
117
+
118
+ /** VKUI `View.history` prop: panels in open order. */
119
+ export function usePanelsHistory(activePanel: string): string[] {
120
+ return panelsHistoryOf(usePanelsStack(activePanel));
121
+ }
122
+
123
+ /**
124
+ * The deepest matched panel's route id — what "the active panel" means
125
+ * when every panel is a permanently mounted static child.
126
+ */
127
+ function useActivePanelRouteId(): string | null {
128
+ const router = useRouter();
129
+ return useRouterState({
130
+ select: (state): string | null => {
131
+ for (let i = state.matches.length - 1; i >= 0; i--) {
132
+ const routeId = state.matches[i]?.routeId;
133
+ if (routeId === undefined) {
134
+ continue;
135
+ }
136
+ if (looseRouteOf(router, routeId)?.options.staticData?.panel !== undefined) {
137
+ return routeId;
138
+ }
139
+ }
140
+ return null;
141
+ },
142
+ });
143
+ }
144
+
145
+ /**
146
+ * The old router's `useEnableSwipeBack`: override the automatic
147
+ * swipe-back decision while it applies. Panels are permanently mounted
148
+ * static children here, so pass the panel ref — the override applies only
149
+ * while that panel is the deepest matched one and is released when the
150
+ * user navigates away. Without a ref the override is app-global.
151
+ *
152
+ * `false` turns the native gesture off (drawing pads, drag surfaces);
153
+ * `true` forces it on even on the first entry (the system back then
154
+ * closes the app — you asked for it). `createSwipeBackManager({
155
+ * enabled: false })` is the hard opt-out that wins over this hook.
156
+ */
157
+ export function useEnableSwipeBack(enabled: boolean, ref?: PanelRef | string): void {
158
+ const activeRouteId = useActivePanelRouteId();
159
+ const routeId = typeof ref === "string" ? ref : ref?.routeId;
160
+ const applies = ref === undefined ? true : routeId !== undefined && routeId === activeRouteId;
161
+ useEffect(() => {
162
+ if (!applies) {
163
+ return;
164
+ }
165
+ setSwipeBackOverride(enabled);
166
+ return () => setSwipeBackOverride(null);
167
+ }, [applies, enabled]);
168
+ }
package/src/react.ts ADDED
@@ -0,0 +1,11 @@
1
+ export {
2
+ useLastWhileUndefined,
3
+ usePanelParams,
4
+ usePanelSearch,
5
+ usePanelLoaderData,
6
+ useModalProps,
7
+ usePanelsStack,
8
+ usePanelsHistory,
9
+ useEnableSwipeBack,
10
+ type PanelRef,
11
+ } from "./react/hooks.ts";