@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,75 @@
1
+ import { NavigateOptions } from "@tanstack/react-router";
2
+ //#region src/preset/navigator.d.ts
3
+ /**
4
+ * The parity navigator over TanStack primitives: push/replace/back/
5
+ * backToFirst/showModal/hideModal/showPopout/hidePopout. Modal→modal
6
+ * navigation REPLACES the entry (the host closes one modal before opening
7
+ * the next — there is no modal stack); back from a modal returns to its
8
+ * panel. `push`/`replace`/`showModal` accept full TanStack NavigateOptions
9
+ * — typed by the registered route tree.
10
+ */
11
+ export interface VkNavigator {
12
+ push<TParams extends Record<string, string>>(page: PanelPage<TParams>, params: TParams, options?: Omit<NavigateOptions, "to" | "params">): Promise<void>;
13
+ push(options: NavigateOptions): Promise<void>;
14
+ replace<TParams extends Record<string, string>>(page: PanelPage<TParams>, params: TParams, options?: Omit<NavigateOptions, "to" | "params">): Promise<void>;
15
+ replace(options: NavigateOptions): Promise<void>;
16
+ back(): void;
17
+ /** Pop all the way to the first history entry. */
18
+ backToFirst(): void;
19
+ /** Open a modal route; replaces the entry if another modal is open.
20
+ * `props` (serializable) land in history.state and are read by
21
+ * `useModalProps()` inside the modal component. */
22
+ showModal(options: NavigateOptions & {
23
+ props?: unknown;
24
+ }): Promise<void>;
25
+ /** Close the current modal (back, or replace to the panel on first entry). */
26
+ hideModal(): void;
27
+ /**
28
+ * Open a global (`?modal=<id>`) modal from any panel, masking the URL so
29
+ * the visible path stays at the panel. Back closes it.
30
+ */
31
+ showGlobalModal(id: string, options?: {
32
+ state?: Record<string, unknown>;
33
+ props?: unknown;
34
+ }): Promise<void>;
35
+ showPopout(id: string, props?: Record<string, unknown>): void;
36
+ hidePopout(): void;
37
+ /**
38
+ * Block navigation while `onLeave` returns true (old router's
39
+ * `RouteNavigator.block`). The callback receives the current and the
40
+ * next location plus the action — PUSH/REPLACE as-is, back/forward/go
41
+ * mapped to POP (old-router `BlockerFunction` parity). Returns the
42
+ * unblock function.
43
+ */
44
+ block(onLeave: (args: BlockerArgs) => boolean): () => void;
45
+ }
46
+ export interface BlockerArgs {
47
+ /** The location the navigation starts from (TanStack history location). */
48
+ currentLocation: {
49
+ href: string;
50
+ pathname: string;
51
+ };
52
+ /** The location the navigation is heading to. */
53
+ nextLocation: {
54
+ href: string;
55
+ pathname: string;
56
+ };
57
+ /** PUSH/REPLACE as-is; back, forward and go are one POP (old-router parity). */
58
+ action: "PUSH" | "REPLACE" | "POP";
59
+ }
60
+ /**
61
+ * A typed page object — a panel definition (`defineView().panel(...)`) or
62
+ * a panel handle (`app.panels[...]`). The params type flows from the
63
+ * definition's phantom `types`, so `navigator.push(postPanel, { id })`
64
+ * type-checks the params (old-router `Page`/`PageWithParams` parity,
65
+ * without the `hasParams` discriminator quirk).
66
+ */
67
+ export interface PanelPage<TParams = Record<string, string>> {
68
+ path: string;
69
+ types?: {
70
+ params: TParams;
71
+ };
72
+ }
73
+ export declare function useNavigator(): VkNavigator;
74
+ //#endregion
75
+ //# sourceMappingURL=navigator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"navigator.d.ts","names":[],"sources":["../../src/preset/navigator.ts"],"mappings":";;;;;;;;;;iBAmBiB;EAChB,KAAK,gBAAgB,wBACpB,MAAM,UAAU,UAChB,QAAQ,SACR,UAAU,KAAK,oCACb;EACH,KAAK,SAAS,kBAAkB;EAChC,QAAQ,gBAAgB,wBACvB,MAAM,UAAU,UAChB,QAAQ,SACR,UAAU,KAAK,oCACb;EACH,QAAQ,SAAS,kBAAkB;EACnC;;EAEA;;;;EAIA,UAAU,SAAS;IAAoB;MAAoB;;EAE3D;;;;;EAKA,gBACC,YACA;IAAY,QAAQ;IAAyB;MAC3C;EACH,WAAW,YAAY,QAAQ;EAC/B;;;;;;;;EAQA,MAAM,UAAU,MAAM;;iBAON;;EAEhB;IAAmB;IAAc;;;EAEjC;IAAgB;IAAc;;;EAE9B;;;;;;;;;iBAUgB,UAAU,UAAU;EACpC;EACA;IAAU,QAAQ;;;wBAGH,gBAAgB"}
@@ -0,0 +1,116 @@
1
+ import { MODAL_STATE_KEY } from "../core/state-keys.js";
2
+ import { looseRouteOf } from "../core/loose-route.js";
3
+ import { useHidePopout, useShowPopout } from "../vkui/popouts.js";
4
+ import { useNavigate, useRouter, useRouterState } from "@tanstack/react-router";
5
+ //#region src/preset/navigator.ts
6
+ function isModalRouteId(router, routeId) {
7
+ return looseRouteOf(router, routeId)?.options.staticData?.modal !== void 0;
8
+ }
9
+ function useNavigator() {
10
+ const router = useRouter();
11
+ const navigate = useNavigate();
12
+ const location = useRouterState({ select: (state) => state.location });
13
+ const modalOpen = useRouterState({ select: (state) => state.matches.some((match) => isModalRouteId(router, match.routeId)) });
14
+ const showPopout = useShowPopout();
15
+ const hidePopout = useHidePopout();
16
+ /** `"index"` and an optional leading slash normalize to the full path. */
17
+ const pageTo = (page) => page.path === "index" ? "/" : `/${page.path.replace(/^\//, "")}`;
18
+ return {
19
+ push: ((pageOrOptions, params, rest) => params === void 0 ? navigate({
20
+ ...pageOrOptions,
21
+ replace: false
22
+ }) : navigate({
23
+ to: pageTo(pageOrOptions),
24
+ params,
25
+ replace: false,
26
+ ...rest
27
+ })),
28
+ replace: ((pageOrOptions, params, rest) => params === void 0 ? navigate({
29
+ ...pageOrOptions,
30
+ replace: true
31
+ }) : navigate({
32
+ to: pageTo(pageOrOptions),
33
+ params,
34
+ replace: true,
35
+ ...rest
36
+ })),
37
+ back: () => {
38
+ router.history.back();
39
+ },
40
+ backToFirst: () => {
41
+ const index = location.state?.__TSR_index ?? 0;
42
+ if (index > 0) router.history.go(-index);
43
+ },
44
+ showModal: ({ props, ...options }) => {
45
+ let nested = false;
46
+ if (modalOpen && typeof options.to === "string") for (let i = router.state.matches.length - 1; i >= 0; i--) {
47
+ const matchRouteId = router.state.matches[i]?.routeId;
48
+ if (matchRouteId === void 0) continue;
49
+ const route = looseRouteOf(router, matchRouteId);
50
+ if (route?.options.staticData?.modal === void 0) continue;
51
+ nested = route.fullPath !== void 0 && options.to.startsWith(`${route.fullPath}/`);
52
+ break;
53
+ }
54
+ const withProps = props !== void 0 ? { state: {
55
+ ...options.state,
56
+ [MODAL_STATE_KEY]: { props }
57
+ } } : {};
58
+ return navigate({
59
+ ...options,
60
+ search: options.search ?? true,
61
+ replace: nested ? false : modalOpen,
62
+ ...withProps
63
+ });
64
+ },
65
+ hideModal: () => {
66
+ if (router.history.canGoBack()) {
67
+ router.history.back();
68
+ return;
69
+ }
70
+ const parentPath = location.pathname.replace(/\/[^/]+$/, "") || "/";
71
+ router.navigate({
72
+ href: parentPath,
73
+ replace: true
74
+ });
75
+ },
76
+ showPopout: (id, props) => {
77
+ showPopout(id, props);
78
+ },
79
+ hidePopout: () => {
80
+ hidePopout();
81
+ },
82
+ showGlobalModal: (id, { state, props, ...rest } = {}) => {
83
+ const { pathname, searchStr, hash } = router.state.location;
84
+ const params = new URLSearchParams(searchStr.replace(/^\?/, ""));
85
+ const already = params.get("modal") === id;
86
+ params.set("modal", id);
87
+ const href = `${pathname}?${params.toString()}${hash}`;
88
+ const maskParams = new URLSearchParams(params);
89
+ maskParams.delete("modal");
90
+ const maskQuery = maskParams.toString();
91
+ const maskHref = `${pathname}${maskQuery !== "" ? `?${maskQuery}` : ""}${hash}`;
92
+ const nextState = state !== void 0 || props !== void 0 ? { state: {
93
+ ...state,
94
+ ...props !== void 0 ? { [MODAL_STATE_KEY]: { props } } : {}
95
+ } } : {};
96
+ return navigate({
97
+ href,
98
+ mask: { to: maskHref },
99
+ replace: already || modalOpen,
100
+ ...rest,
101
+ ...nextState
102
+ });
103
+ },
104
+ block: (onLeave) => {
105
+ return router.history.block({ blockerFn: (args) => onLeave({
106
+ currentLocation: args.currentLocation,
107
+ nextLocation: args.nextLocation,
108
+ action: args.action === "PUSH" || args.action === "REPLACE" ? args.action : "POP"
109
+ }) });
110
+ }
111
+ };
112
+ }
113
+ //#endregion
114
+ export { useNavigator };
115
+
116
+ //# sourceMappingURL=navigator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"navigator.js","names":[],"sources":["../../src/preset/navigator.ts"],"sourcesContent":["import {\n\tuseNavigate,\n\tuseRouter,\n\tuseRouterState,\n\ttype NavigateOptions,\n} from \"@tanstack/react-router\";\nimport { looseRouteOf } from \"../core/loose-route.ts\";\nimport { MODAL_STATE_KEY } from \"../core/state-keys.ts\";\nimport { useHidePopout, useShowPopout } from \"../vkui/popouts.tsx\";\n\n/**\n * The parity navigator over TanStack primitives: push/replace/back/\n * backToFirst/showModal/hideModal/showPopout/hidePopout. Modal→modal\n * navigation REPLACES the entry (the host closes one modal before opening\n * the next — there is no modal stack); back from a modal returns to its\n * panel. `push`/`replace`/`showModal` accept full TanStack NavigateOptions\n * — typed by the registered route tree.\n */\n\nexport interface VkNavigator {\n\tpush<TParams extends Record<string, string>>(\n\t\tpage: PanelPage<TParams>,\n\t\tparams: TParams,\n\t\toptions?: Omit<NavigateOptions, \"to\" | \"params\">,\n\t): Promise<void>;\n\tpush(options: NavigateOptions): Promise<void>;\n\treplace<TParams extends Record<string, string>>(\n\t\tpage: PanelPage<TParams>,\n\t\tparams: TParams,\n\t\toptions?: Omit<NavigateOptions, \"to\" | \"params\">,\n\t): Promise<void>;\n\treplace(options: NavigateOptions): Promise<void>;\n\tback(): void;\n\t/** Pop all the way to the first history entry. */\n\tbackToFirst(): void;\n\t/** Open a modal route; replaces the entry if another modal is open.\n\t * `props` (serializable) land in history.state and are read by\n\t * `useModalProps()` inside the modal component. */\n\tshowModal(options: NavigateOptions & { props?: unknown }): Promise<void>;\n\t/** Close the current modal (back, or replace to the panel on first entry). */\n\thideModal(): void;\n\t/**\n\t * Open a global (`?modal=<id>`) modal from any panel, masking the URL so\n\t * the visible path stays at the panel. Back closes it.\n\t */\n\tshowGlobalModal(\n\t\tid: string,\n\t\toptions?: { state?: Record<string, unknown>; props?: unknown },\n\t): Promise<void>;\n\tshowPopout(id: string, props?: Record<string, unknown>): void;\n\thidePopout(): void;\n\t/**\n\t * Block navigation while `onLeave` returns true (old router's\n\t * `RouteNavigator.block`). The callback receives the current and the\n\t * next location plus the action — PUSH/REPLACE as-is, back/forward/go\n\t * mapped to POP (old-router `BlockerFunction` parity). Returns the\n\t * unblock function.\n\t */\n\tblock(onLeave: (args: BlockerArgs) => boolean): () => void;\n}\n\nfunction isModalRouteId(router: ReturnType<typeof useRouter>, routeId: string): boolean {\n\treturn looseRouteOf(router, routeId)?.options.staticData?.modal !== undefined;\n}\n\nexport interface BlockerArgs {\n\t/** The location the navigation starts from (TanStack history location). */\n\tcurrentLocation: { href: string; pathname: string };\n\t/** The location the navigation is heading to. */\n\tnextLocation: { href: string; pathname: string };\n\t/** PUSH/REPLACE as-is; back, forward and go are one POP (old-router parity). */\n\taction: \"PUSH\" | \"REPLACE\" | \"POP\";\n}\n\n/**\n * A typed page object — a panel definition (`defineView().panel(...)`) or\n * a panel handle (`app.panels[...]`). The params type flows from the\n * definition's phantom `types`, so `navigator.push(postPanel, { id })`\n * type-checks the params (old-router `Page`/`PageWithParams` parity,\n * without the `hasParams` discriminator quirk).\n */\nexport interface PanelPage<TParams = Record<string, string>> {\n\tpath: string;\n\ttypes?: { params: TParams };\n}\n\nexport function useNavigator(): VkNavigator {\n\tconst router = useRouter();\n\tconst navigate = useNavigate();\n\tconst location = useRouterState({ select: state => state.location });\n\tconst modalOpen = useRouterState({\n\t\tselect: state => state.matches.some(match => isModalRouteId(router, match.routeId)),\n\t});\n\tconst showPopout = useShowPopout();\n\tconst hidePopout = useHidePopout();\n\n\t/** `\"index\"` and an optional leading slash normalize to the full path. */\n\tconst pageTo = (page: PanelPage): string =>\n\t\tpage.path === \"index\" ? \"/\" : `/${page.path.replace(/^\\//, \"\")}`;\n\n\treturn {\n\t\tpush: ((\n\t\t\tpageOrOptions: NavigateOptions | PanelPage,\n\t\t\tparams?: Record<string, string>,\n\t\t\trest?: Omit<NavigateOptions, \"to\" | \"params\">,\n\t\t) =>\n\t\t\tparams === undefined\n\t\t\t\t? navigate({\n\t\t\t\t\t\t...(pageOrOptions as NavigateOptions),\n\t\t\t\t\t\treplace: false,\n\t\t\t\t\t})\n\t\t\t\t: navigate({\n\t\t\t\t\t\tto: pageTo(pageOrOptions as PanelPage),\n\t\t\t\t\t\tparams,\n\t\t\t\t\t\treplace: false,\n\t\t\t\t\t\t...rest,\n\t\t\t\t\t})) as VkNavigator[\"push\"],\n\t\treplace: ((\n\t\t\tpageOrOptions: NavigateOptions | PanelPage,\n\t\t\tparams?: Record<string, string>,\n\t\t\trest?: Omit<NavigateOptions, \"to\" | \"params\">,\n\t\t) =>\n\t\t\tparams === undefined\n\t\t\t\t? navigate({\n\t\t\t\t\t\t...(pageOrOptions as NavigateOptions),\n\t\t\t\t\t\treplace: true,\n\t\t\t\t\t})\n\t\t\t\t: navigate({\n\t\t\t\t\t\tto: pageTo(pageOrOptions as PanelPage),\n\t\t\t\t\t\tparams,\n\t\t\t\t\t\treplace: true,\n\t\t\t\t\t\t...rest,\n\t\t\t\t\t})) as VkNavigator[\"replace\"],\n\t\tback: () => {\n\t\t\trouter.history.back();\n\t\t},\n\t\tbackToFirst: () => {\n\t\t\tconst index =\n\t\t\t\t(location.state as { __TSR_index?: number } | undefined)?.__TSR_index ?? 0;\n\t\t\tif (index > 0) {\n\t\t\t\trouter.history.go(-index);\n\t\t\t}\n\t\t},\n\t\tshowModal: ({ props, ...options }) => {\n\t\t\t// nested modal: the target route is a CHILD of the currently open\n\t\t\t// modal — PUSH (back unwinds the stack); sibling modals keep the\n\t\t\t// old-router replace parity\n\t\t\tlet nested = false;\n\t\t\tif (modalOpen && typeof options.to === \"string\") {\n\t\t\t\tfor (let i = router.state.matches.length - 1; i >= 0; i--) {\n\t\t\t\t\tconst matchRouteId = router.state.matches[i]?.routeId;\n\t\t\t\t\tif (matchRouteId === undefined) {\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t\tconst route = looseRouteOf(router, matchRouteId);\n\t\t\t\t\tif (route?.options.staticData?.modal === undefined) {\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t\tnested =\n\t\t\t\t\t\troute.fullPath !== undefined && options.to.startsWith(`${route.fullPath}/`);\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t\t// TanStack types history state as its own updater shape — arbitrary\n\t\t\t// serializable keys go through a cast (same as the popout state)\n\t\t\tconst withProps =\n\t\t\t\tprops !== undefined\n\t\t\t\t\t? {\n\t\t\t\t\t\t\tstate: {\n\t\t\t\t\t\t\t\t...(options.state as Record<string, unknown> | undefined),\n\t\t\t\t\t\t\t\t[MODAL_STATE_KEY]: { props },\n\t\t\t\t\t\t\t} as never,\n\t\t\t\t\t\t}\n\t\t\t\t\t: {};\n\t\t\treturn navigate({\n\t\t\t\t...options,\n\t\t\t\t// a modal open never changes the panel behind it — carry the\n\t\t\t\t// panel's search (?tab=…) onto the modal entry, or the covered\n\t\t\t\t// panel would snap to its default search for the whole time the\n\t\t\t\t// modal is open (and snap back on close). An explicit `search`\n\t\t\t\t// from the caller always wins.\n\t\t\t\tsearch: options.search ?? true,\n\t\t\t\treplace: nested ? false : modalOpen,\n\t\t\t\t...withProps,\n\t\t\t});\n\t\t},\n\t\thideModal: () => {\n\t\t\tif (router.history.canGoBack()) {\n\t\t\t\trouter.history.back();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// deep-linked modal on the first entry: replace down to the panel\n\t\t\tconst parentPath = location.pathname.replace(/\\/[^/]+$/, \"\") || \"/\";\n\t\t\tvoid router.navigate({ href: parentPath, replace: true });\n\t\t},\n\t\tshowPopout: (id, props) => {\n\t\t\tshowPopout(id, props);\n\t\t},\n\t\thidePopout: () => {\n\t\t\thidePopout();\n\t\t},\n\t\tshowGlobalModal: (id, { state, props, ...rest } = {}) => {\n\t\t\tconst { pathname, searchStr, hash } = router.state.location;\n\t\t\tconst params = new URLSearchParams(searchStr.replace(/^\\?/, \"\"));\n\t\t\tconst already = params.get(\"modal\") === id;\n\t\t\tparams.set(\"modal\", id);\n\t\t\tconst href = `${pathname}?${params.toString()}${hash}`;\n\t\t\t// the mask keeps the visible URL at the panel path while the modal\n\t\t\t// is open (the modal param rides in history.state)\n\t\t\tconst maskParams = new URLSearchParams(params);\n\t\t\tmaskParams.delete(\"modal\");\n\t\t\tconst maskQuery = maskParams.toString();\n\t\t\tconst maskHref = `${pathname}${maskQuery !== \"\" ? `?${maskQuery}` : \"\"}${hash}`;\n\t\t\tconst nextState =\n\t\t\t\tstate !== undefined || props !== undefined\n\t\t\t\t\t? {\n\t\t\t\t\t\t\tstate: {\n\t\t\t\t\t\t\t\t...state,\n\t\t\t\t\t\t\t\t...(props !== undefined ? { [MODAL_STATE_KEY]: { props } } : {}),\n\t\t\t\t\t\t\t} as never,\n\t\t\t\t\t\t}\n\t\t\t\t\t: {};\n\t\t\treturn navigate({\n\t\t\t\thref,\n\t\t\t\tmask: { to: maskHref },\n\t\t\t\treplace: already || modalOpen,\n\t\t\t\t...rest,\n\t\t\t\t...nextState,\n\t\t\t});\n\t\t},\n\t\tblock: onLeave => {\n\t\t\tconst unblock = router.history.block({\n\t\t\t\t// args infer as TanStack's BlockerFnArgs (HistoryLocation × 2 +\n\t\t\t\t// action) — assignable to our structural BlockerArgs\n\t\t\t\tblockerFn: args =>\n\t\t\t\t\tonLeave({\n\t\t\t\t\t\tcurrentLocation: args.currentLocation,\n\t\t\t\t\t\tnextLocation: args.nextLocation,\n\t\t\t\t\t\t// TanStack distinguishes BACK/FORWARD/GO; the old router's\n\t\t\t\t\t\t// BlockerFunction saw one POP — keep the parity\n\t\t\t\t\t\taction:\n\t\t\t\t\t\t\targs.action === \"PUSH\" || args.action === \"REPLACE\"\n\t\t\t\t\t\t\t\t? args.action\n\t\t\t\t\t\t\t\t: \"POP\",\n\t\t\t\t\t}),\n\t\t\t});\n\t\t\treturn unblock;\n\t\t},\n\t};\n}\n"],"mappings":"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"}
@@ -0,0 +1,17 @@
1
+ import { createVkHistory } from "./core/vk-history.js";
2
+ import { VkBridgeLike, normalizeBridgeEvent } from "./core/bridge-like.js";
3
+ import { createBridgeSync } from "./core/bridge-sync.js";
4
+ import { createSwipeBackManager } from "./core/swipe-back.js";
5
+ import { ModalCardSurface, ModalEntry, ModalPageSurface, Modals } from "./vkui/modals.js";
6
+ import { Popouts, useHidePopout, usePopoutState, useShowPopout } from "./vkui/popouts.js";
7
+ import { PanelDefinition, definePanel, panelRouteId } from "./preset/define-panel.js";
8
+ import { ViewDefinition, defineView } from "./preset/define-view.js";
9
+ import { AppHandle, AppOptions, ModalComponent, ModalDeclaration, ModalEntryComponent, PanelHandle, PanelHandlesOf, PanelOptions, TypedAppHandle, ViewOptions, createApp, panel } from "./preset/builders.js";
10
+ import { AppRootShell, useActiveStory, useInflate } from "./preset/app.js";
11
+ import { deriveInflateChain } from "./preset/inflate.js";
12
+ import { VkNavigator, useNavigator } from "./preset/navigator.js";
13
+ import { VkView } from "./vkui/vk-view.js";
14
+ import { NotFound } from "./vkui/not-found.js";
15
+ import "./vkui.js";
16
+ import { PanelRef, useEnableSwipeBack, useModalProps, usePanelLoaderData, usePanelParams, usePanelSearch, usePanelsHistory } from "./react/hooks.js";
17
+ export { type AppHandle, type AppOptions, AppRootShell, ModalCardSurface, type ModalComponent, type ModalDeclaration, type ModalEntry, type ModalEntryComponent, ModalPageSurface, Modals, NotFound, type PanelDefinition, type PanelHandle, type PanelHandlesOf, type PanelOptions, type PanelRef, Popouts, type TypedAppHandle, type ViewDefinition, type ViewOptions, type VkBridgeLike, type VkNavigator, VkView, createApp, createBridgeSync, createSwipeBackManager, createVkHistory, definePanel, defineView, deriveInflateChain, normalizeBridgeEvent, panel, panelRouteId, useActiveStory, useEnableSwipeBack, useHidePopout, useInflate, useModalProps, useNavigator, usePanelLoaderData, usePanelParams, usePanelSearch, usePanelsHistory, usePopoutState, useShowPopout };
package/dist/preset.js ADDED
@@ -0,0 +1,16 @@
1
+ import { createVkHistory } from "./core/vk-history.js";
2
+ import { normalizeBridgeEvent } from "./core/bridge-like.js";
3
+ import { createBridgeSync } from "./core/bridge-sync.js";
4
+ import { createSwipeBackManager } from "./core/swipe-back.js";
5
+ import { useEnableSwipeBack, useModalProps, usePanelLoaderData, usePanelParams, usePanelSearch, usePanelsHistory } from "./react/hooks.js";
6
+ import { VkView } from "./vkui/vk-view.js";
7
+ import { ModalCardSurface, ModalPageSurface, Modals } from "./vkui/modals.js";
8
+ import { Popouts, useHidePopout, usePopoutState, useShowPopout } from "./vkui/popouts.js";
9
+ import { NotFound } from "./vkui/not-found.js";
10
+ import { deriveInflateChain } from "./preset/inflate.js";
11
+ import { AppRootShell, useActiveStory, useInflate } from "./preset/app.js";
12
+ import { definePanel, panelRouteId } from "./preset/define-panel.js";
13
+ import { defineView } from "./preset/define-view.js";
14
+ import { createApp, panel } from "./preset/builders.js";
15
+ import { useNavigator } from "./preset/navigator.js";
16
+ export { AppRootShell, ModalCardSurface, ModalPageSurface, Modals, NotFound, Popouts, VkView, createApp, createBridgeSync, createSwipeBackManager, createVkHistory, definePanel, defineView, deriveInflateChain, normalizeBridgeEvent, panel, panelRouteId, useActiveStory, useEnableSwipeBack, useHidePopout, useInflate, useModalProps, useNavigator, usePanelLoaderData, usePanelParams, usePanelSearch, usePanelsHistory, usePopoutState, useShowPopout };
@@ -0,0 +1,69 @@
1
+ import { PanelsStackState } from "../core/panels-stack.js";
2
+ //#region src/react/hooks.d.ts
3
+ /**
4
+ * The departing-panel freeze (spike 1): raw TanStack data hooks throw
5
+ * `Invariant failed: Could not find an active match` when the panel's
6
+ * route stops matching while VKUI still keeps the panel mounted during the
7
+ * back animation — the error flies past the router's error boundary and
8
+ * remounts the whole tree. These hooks never throw and retain the last
9
+ * resolved value while the panel is mounted but unmatched.
10
+ *
11
+ * Pass the TYPED panel handle (`app.panels["feed/post/$id"]`) — params /
12
+ * search / loaderData are derived from the panel's declared options
13
+ * (validateSearch return, loader return, $param path segments). A plain
14
+ * route-id string also works, with loose types.
15
+ */
16
+ /**
17
+ * Structural twin of the preset's `PanelHandle` — the phantom `types`
18
+ * member carries the panel's data types without coupling this layer to
19
+ * the preset.
20
+ */
21
+ export interface PanelRef<TParams = Record<string, string>, TSearch = Record<string, unknown>, TLoaderData = unknown> {
22
+ readonly routeId: string;
23
+ readonly types?: {
24
+ params: TParams;
25
+ search: TSearch;
26
+ loaderData: TLoaderData;
27
+ };
28
+ }
29
+ export declare function useLastWhileUndefined<T>(value: T | undefined): T | undefined;
30
+ export declare function usePanelParams<P extends Record<string, string>>(ref: PanelRef<P | undefined, any, any>): P;
31
+ export declare function usePanelParams(from: string): Record<string, string>;
32
+ export declare function usePanelSearch<S>(ref: PanelRef<any, S | undefined, any>): S | undefined;
33
+ export declare function usePanelSearch(from: string): Record<string, unknown>;
34
+ export declare function usePanelLoaderData<D>(ref: PanelRef<any, any, D | undefined>): D | undefined;
35
+ export declare function usePanelLoaderData(from: string): unknown;
36
+ /**
37
+ * Runtime props of the open modal — `showModal({ to, params, props })` /
38
+ * `showGlobalModal(id, { props })` stash them in `history.state` (so they
39
+ * survive reload and back/forward) and the modal entry component reads them
40
+ * here. Serializable values only (structured clone); same freeze semantics
41
+ * as the panel hooks — the departing modal keeps its last props while it
42
+ * animates out.
43
+ */
44
+ export declare function useModalProps<T = unknown>(): T | undefined;
45
+ /**
46
+ * Mirror of the history stack in panel ids, driven by `__TSR_index`.
47
+ * Applies only when the router is idle — between the location commit and
48
+ * the match resolution there is an intermediate render with a stale
49
+ * activePanel (spike 1: `[home, home]` flash, self-healed by the
50
+ * replace-on-same-index rule).
51
+ */
52
+ export declare function usePanelsStack(activePanel: string): PanelsStackState;
53
+ /** VKUI `View.history` prop: panels in open order. */
54
+ export declare function usePanelsHistory(activePanel: string): string[];
55
+ /**
56
+ * The old router's `useEnableSwipeBack`: override the automatic
57
+ * swipe-back decision while it applies. Panels are permanently mounted
58
+ * static children here, so pass the panel ref — the override applies only
59
+ * while that panel is the deepest matched one and is released when the
60
+ * user navigates away. Without a ref the override is app-global.
61
+ *
62
+ * `false` turns the native gesture off (drawing pads, drag surfaces);
63
+ * `true` forces it on even on the first entry (the system back then
64
+ * closes the app — you asked for it). `createSwipeBackManager({
65
+ * enabled: false })` is the hard opt-out that wins over this hook.
66
+ */
67
+ export declare function useEnableSwipeBack(enabled: boolean, ref?: PanelRef | string): void;
68
+ //#endregion
69
+ //# sourceMappingURL=hooks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hooks.d.ts","names":[],"sources":["../../src/react/hooks.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;iBA+BiB,SAChB,UAAU,wBACV,UAAU,yBACV;WAES;WACA;IAAU,QAAQ;IAAS,QAAQ;IAAS,YAAY;;;wBAGlD,sBAAsB,GAAG,OAAO,gBAAgB;wBAoBhD,eAAe,UAAU,wBACxC,KAAK,SAAS,2BACZ;wBACa,eAAe,eAAe;wBAK9B,eAAe,GAAG,KAAK,cAAc,sBAAsB;wBAC3D,eAAe,eAAe;wBAK9B,mBAAmB,GAAG,KAAK,mBAAmB,iBAAiB;wBAC/D,mBAAmB;;;;;;;;;wBAanB,cAAc,gBAAgB;;;;;;;;wBAY9B,eAAe,sBAAsB;;wBAkBrC,iBAAiB;;;;;;;;;;;;;wBAsCjB,mBAAmB,kBAAkB,MAAM"}
@@ -0,0 +1,101 @@
1
+ import { applyLocation, initialPanelsStack, panelsHistoryOf } from "../core/panels-stack.js";
2
+ import { readModalState } from "../core/state-keys.js";
3
+ import { setSwipeBackOverride } from "../core/swipe-back.js";
4
+ import { looseRouteOf } from "../core/loose-route.js";
5
+ import { useMatch, useRouter, useRouterState } from "@tanstack/react-router";
6
+ import { useEffect, useRef } from "react";
7
+ //#region src/react/hooks.ts
8
+ function useLastWhileUndefined(value) {
9
+ const ref = useRef(value);
10
+ if (value !== void 0) ref.current = value;
11
+ return value === void 0 ? ref.current : value;
12
+ }
13
+ /** Shared body of the panel data hooks — they differ only in the match field. */
14
+ function usePanelMatchField(refOrFrom, field) {
15
+ const routeId = typeof refOrFrom === "string" ? refOrFrom : refOrFrom.routeId;
16
+ return useLastWhileUndefined(useMatch({
17
+ from: routeId,
18
+ shouldThrow: false
19
+ })?.[field]);
20
+ }
21
+ function usePanelParams(refOrFrom) {
22
+ return usePanelMatchField(refOrFrom, "params") ?? {};
23
+ }
24
+ function usePanelSearch(refOrFrom) {
25
+ return usePanelMatchField(refOrFrom, "search");
26
+ }
27
+ function usePanelLoaderData(refOrFrom) {
28
+ return usePanelMatchField(refOrFrom, "loaderData");
29
+ }
30
+ /**
31
+ * Runtime props of the open modal — `showModal({ to, params, props })` /
32
+ * `showGlobalModal(id, { props })` stash them in `history.state` (so they
33
+ * survive reload and back/forward) and the modal entry component reads them
34
+ * here. Serializable values only (structured clone); same freeze semantics
35
+ * as the panel hooks — the departing modal keeps its last props while it
36
+ * animates out.
37
+ */
38
+ function useModalProps() {
39
+ return useLastWhileUndefined(useRouterState({ select: (state) => readModalState(state.location.state) })?.props);
40
+ }
41
+ /**
42
+ * Mirror of the history stack in panel ids, driven by `__TSR_index`.
43
+ * Applies only when the router is idle — between the location commit and
44
+ * the match resolution there is an intermediate render with a stale
45
+ * activePanel (spike 1: `[home, home]` flash, self-healed by the
46
+ * replace-on-same-index rule).
47
+ */
48
+ function usePanelsStack(activePanel) {
49
+ const snapshot = useRouterState({ select: (state) => ({
50
+ status: state.status,
51
+ index: state.location.state?.__TSR_index ?? null
52
+ }) });
53
+ const ref = useRef(initialPanelsStack());
54
+ if (snapshot.status === "idle") ref.current = applyLocation(ref.current, snapshot.index, activePanel);
55
+ return ref.current;
56
+ }
57
+ /** VKUI `View.history` prop: panels in open order. */
58
+ function usePanelsHistory(activePanel) {
59
+ return panelsHistoryOf(usePanelsStack(activePanel));
60
+ }
61
+ /**
62
+ * The deepest matched panel's route id — what "the active panel" means
63
+ * when every panel is a permanently mounted static child.
64
+ */
65
+ function useActivePanelRouteId() {
66
+ const router = useRouter();
67
+ return useRouterState({ select: (state) => {
68
+ for (let i = state.matches.length - 1; i >= 0; i--) {
69
+ const routeId = state.matches[i]?.routeId;
70
+ if (routeId === void 0) continue;
71
+ if (looseRouteOf(router, routeId)?.options.staticData?.panel !== void 0) return routeId;
72
+ }
73
+ return null;
74
+ } });
75
+ }
76
+ /**
77
+ * The old router's `useEnableSwipeBack`: override the automatic
78
+ * swipe-back decision while it applies. Panels are permanently mounted
79
+ * static children here, so pass the panel ref — the override applies only
80
+ * while that panel is the deepest matched one and is released when the
81
+ * user navigates away. Without a ref the override is app-global.
82
+ *
83
+ * `false` turns the native gesture off (drawing pads, drag surfaces);
84
+ * `true` forces it on even on the first entry (the system back then
85
+ * closes the app — you asked for it). `createSwipeBackManager({
86
+ * enabled: false })` is the hard opt-out that wins over this hook.
87
+ */
88
+ function useEnableSwipeBack(enabled, ref) {
89
+ const activeRouteId = useActivePanelRouteId();
90
+ const routeId = typeof ref === "string" ? ref : ref?.routeId;
91
+ const applies = ref === void 0 ? true : routeId !== void 0 && routeId === activeRouteId;
92
+ useEffect(() => {
93
+ if (!applies) return;
94
+ setSwipeBackOverride(enabled);
95
+ return () => setSwipeBackOverride(null);
96
+ }, [applies, enabled]);
97
+ }
98
+ //#endregion
99
+ export { useEnableSwipeBack, useLastWhileUndefined, useModalProps, usePanelLoaderData, usePanelParams, usePanelSearch, usePanelsHistory, usePanelsStack };
100
+
101
+ //# sourceMappingURL=hooks.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hooks.js","names":[],"sources":["../../src/react/hooks.ts"],"sourcesContent":["import { useMatch, useRouter, useRouterState } from \"@tanstack/react-router\";\nimport { useEffect, useRef } from \"react\";\nimport { looseRouteOf } from \"../core/loose-route.ts\";\nimport {\n\tapplyLocation,\n\tinitialPanelsStack,\n\ttype PanelsStackState,\n\tpanelsHistoryOf,\n} from \"../core/panels-stack.ts\";\nimport { readModalState } from \"../core/state-keys.ts\";\nimport { setSwipeBackOverride } from \"../core/swipe-back.ts\";\n\n/**\n * The departing-panel freeze (spike 1): raw TanStack data hooks throw\n * `Invariant failed: Could not find an active match` when the panel's\n * route stops matching while VKUI still keeps the panel mounted during the\n * back animation — the error flies past the router's error boundary and\n * remounts the whole tree. These hooks never throw and retain the last\n * resolved value while the panel is mounted but unmatched.\n *\n * Pass the TYPED panel handle (`app.panels[\"feed/post/$id\"]`) — params /\n * search / loaderData are derived from the panel's declared options\n * (validateSearch return, loader return, $param path segments). A plain\n * route-id string also works, with loose types.\n */\n\n/**\n * Structural twin of the preset's `PanelHandle` — the phantom `types`\n * member carries the panel's data types without coupling this layer to\n * the preset.\n */\nexport interface PanelRef<\n\tTParams = Record<string, string>,\n\tTSearch = Record<string, unknown>,\n\tTLoaderData = unknown,\n> {\n\treadonly routeId: string;\n\treadonly types?: { params: TParams; search: TSearch; loaderData: TLoaderData };\n}\n\nexport function useLastWhileUndefined<T>(value: T | undefined): T | undefined {\n\tconst ref = useRef(value);\n\tif (value !== undefined) {\n\t\tref.current = value;\n\t}\n\treturn value === undefined ? ref.current : value;\n}\n\n/** Shared body of the panel data hooks — they differ only in the match field. */\nfunction usePanelMatchField(\n\trefOrFrom: PanelRef | string,\n\tfield: \"params\" | \"search\" | \"loaderData\",\n): unknown {\n\tconst routeId = typeof refOrFrom === \"string\" ? refOrFrom : refOrFrom.routeId;\n\tconst match = useMatch({ from: routeId, shouldThrow: false } as never);\n\treturn useLastWhileUndefined(\n\t\t(match as unknown as Record<string, unknown> | undefined)?.[field],\n\t);\n}\n\nexport function usePanelParams<P extends Record<string, string>>(\n\tref: PanelRef<P | undefined, any, any>,\n): P;\nexport function usePanelParams(from: string): Record<string, string>;\nexport function usePanelParams(refOrFrom: PanelRef | string): unknown {\n\treturn usePanelMatchField(refOrFrom, \"params\") ?? {};\n}\n\nexport function usePanelSearch<S>(ref: PanelRef<any, S | undefined, any>): S | undefined;\nexport function usePanelSearch(from: string): Record<string, unknown>;\nexport function usePanelSearch(refOrFrom: PanelRef | string): unknown {\n\treturn usePanelMatchField(refOrFrom, \"search\");\n}\n\nexport function usePanelLoaderData<D>(ref: PanelRef<any, any, D | undefined>): D | undefined;\nexport function usePanelLoaderData(from: string): unknown;\nexport function usePanelLoaderData(refOrFrom: PanelRef | string): unknown {\n\treturn usePanelMatchField(refOrFrom, \"loaderData\");\n}\n\n/**\n * Runtime props of the open modal — `showModal({ to, params, props })` /\n * `showGlobalModal(id, { props })` stash them in `history.state` (so they\n * survive reload and back/forward) and the modal entry component reads them\n * here. Serializable values only (structured clone); same freeze semantics\n * as the panel hooks — the departing modal keeps its last props while it\n * animates out.\n */\nexport function useModalProps<T = unknown>(): T | undefined {\n\tconst modal = useRouterState({ select: state => readModalState(state.location.state) });\n\treturn useLastWhileUndefined(modal?.props) as T | undefined;\n}\n\n/**\n * Mirror of the history stack in panel ids, driven by `__TSR_index`.\n * Applies only when the router is idle — between the location commit and\n * the match resolution there is an intermediate render with a stale\n * activePanel (spike 1: `[home, home]` flash, self-healed by the\n * replace-on-same-index rule).\n */\nexport function usePanelsStack(activePanel: string): PanelsStackState {\n\tconst snapshot = useRouterState({\n\t\tselect: state => ({\n\t\t\tstatus: state.status,\n\t\t\tindex:\n\t\t\t\t(state.location.state as { __TSR_index?: number } | undefined)?.__TSR_index ?? null,\n\t\t}),\n\t});\n\tconst ref = useRef<PanelsStackState>(initialPanelsStack());\n\n\tif (snapshot.status === \"idle\") {\n\t\tref.current = applyLocation(ref.current, snapshot.index, activePanel);\n\t}\n\n\treturn ref.current;\n}\n\n/** VKUI `View.history` prop: panels in open order. */\nexport function usePanelsHistory(activePanel: string): string[] {\n\treturn panelsHistoryOf(usePanelsStack(activePanel));\n}\n\n/**\n * The deepest matched panel's route id — what \"the active panel\" means\n * when every panel is a permanently mounted static child.\n */\nfunction useActivePanelRouteId(): string | null {\n\tconst router = useRouter();\n\treturn useRouterState({\n\t\tselect: (state): string | null => {\n\t\t\tfor (let i = state.matches.length - 1; i >= 0; i--) {\n\t\t\t\tconst routeId = state.matches[i]?.routeId;\n\t\t\t\tif (routeId === undefined) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif (looseRouteOf(router, routeId)?.options.staticData?.panel !== undefined) {\n\t\t\t\t\treturn routeId;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t});\n}\n\n/**\n * The old router's `useEnableSwipeBack`: override the automatic\n * swipe-back decision while it applies. Panels are permanently mounted\n * static children here, so pass the panel ref — the override applies only\n * while that panel is the deepest matched one and is released when the\n * user navigates away. Without a ref the override is app-global.\n *\n * `false` turns the native gesture off (drawing pads, drag surfaces);\n * `true` forces it on even on the first entry (the system back then\n * closes the app — you asked for it). `createSwipeBackManager({\n * enabled: false })` is the hard opt-out that wins over this hook.\n */\nexport function useEnableSwipeBack(enabled: boolean, ref?: PanelRef | string): void {\n\tconst activeRouteId = useActivePanelRouteId();\n\tconst routeId = typeof ref === \"string\" ? ref : ref?.routeId;\n\tconst applies = ref === undefined ? true : routeId !== undefined && routeId === activeRouteId;\n\tuseEffect(() => {\n\t\tif (!applies) {\n\t\t\treturn;\n\t\t}\n\t\tsetSwipeBackOverride(enabled);\n\t\treturn () => setSwipeBackOverride(null);\n\t}, [applies, enabled]);\n}\n"],"mappings":";;;;;;;AAwCA,SAAgB,sBAAyB,OAAqC;CAC7E,MAAM,MAAM,OAAO,KAAK;CACxB,IAAI,UAAU,KAAA,GACb,IAAI,UAAU;CAEf,OAAO,UAAU,KAAA,IAAY,IAAI,UAAU;AAC5C;;AAGA,SAAS,mBACR,WACA,OACU;CACV,MAAM,UAAU,OAAO,cAAc,WAAW,YAAY,UAAU;CAEtE,OAAO,sBADO,SAAS;EAAE,MAAM;EAAS,aAAa;CAAM,CAErD,CAAC,GAAsD,MAC7D;AACD;AAMA,SAAgB,eAAe,WAAuC;CACrE,OAAO,mBAAmB,WAAW,QAAQ,KAAK,CAAC;AACpD;AAIA,SAAgB,eAAe,WAAuC;CACrE,OAAO,mBAAmB,WAAW,QAAQ;AAC9C;AAIA,SAAgB,mBAAmB,WAAuC;CACzE,OAAO,mBAAmB,WAAW,YAAY;AAClD;;;;;;;;;AAUA,SAAgB,gBAA4C;CAE3D,OAAO,sBADO,eAAe,EAAE,SAAQ,UAAS,eAAe,MAAM,SAAS,KAAK,EAAE,CACpD,CAAC,EAAE,KAAK;AAC1C;;;;;;;;AASA,SAAgB,eAAe,aAAuC;CACrE,MAAM,WAAW,eAAe,EAC/B,SAAQ,WAAU;EACjB,QAAQ,MAAM;EACd,OACE,MAAM,SAAS,OAAgD,eAAe;CACjF,GACD,CAAC;CACD,MAAM,MAAM,OAAyB,mBAAmB,CAAC;CAEzD,IAAI,SAAS,WAAW,QACvB,IAAI,UAAU,cAAc,IAAI,SAAS,SAAS,OAAO,WAAW;CAGrE,OAAO,IAAI;AACZ;;AAGA,SAAgB,iBAAiB,aAA+B;CAC/D,OAAO,gBAAgB,eAAe,WAAW,CAAC;AACnD;;;;;AAMA,SAAS,wBAAuC;CAC/C,MAAM,SAAS,UAAU;CACzB,OAAO,eAAe,EACrB,SAAS,UAAyB;EACjC,KAAK,IAAI,IAAI,MAAM,QAAQ,SAAS,GAAG,KAAK,GAAG,KAAK;GACnD,MAAM,UAAU,MAAM,QAAQ,EAAE,EAAE;GAClC,IAAI,YAAY,KAAA,GACf;GAED,IAAI,aAAa,QAAQ,OAAO,CAAC,EAAE,QAAQ,YAAY,UAAU,KAAA,GAChE,OAAO;EAET;EACA,OAAO;CACR,EACD,CAAC;AACF;;;;;;;;;;;;;AAcA,SAAgB,mBAAmB,SAAkB,KAA+B;CACnF,MAAM,gBAAgB,sBAAsB;CAC5C,MAAM,UAAU,OAAO,QAAQ,WAAW,MAAM,KAAK;CACrD,MAAM,UAAU,QAAQ,KAAA,IAAY,OAAO,YAAY,KAAA,KAAa,YAAY;CAChF,gBAAgB;EACf,IAAI,CAAC,SACJ;EAED,qBAAqB,OAAO;EAC5B,aAAa,qBAAqB,IAAI;CACvC,GAAG,CAAC,SAAS,OAAO,CAAC;AACtB"}
@@ -0,0 +1,2 @@
1
+ import { PanelRef, useEnableSwipeBack, useLastWhileUndefined, useModalProps, usePanelLoaderData, usePanelParams, usePanelSearch, usePanelsHistory, usePanelsStack } from "./react/hooks.js";
2
+ export { type PanelRef, useEnableSwipeBack, useLastWhileUndefined, useModalProps, usePanelLoaderData, usePanelParams, usePanelSearch, usePanelsHistory, usePanelsStack };
package/dist/react.js ADDED
@@ -0,0 +1 @@
1
+ export { useEnableSwipeBack, useLastWhileUndefined, useModalProps, usePanelLoaderData, usePanelParams, usePanelSearch, usePanelsHistory, usePanelsStack } from "./react/hooks.js";
@@ -0,0 +1,59 @@
1
+ //#region src/vkui/choreography.ts
2
+ const idleChoreography = () => ({
3
+ target: null,
4
+ stack: [],
5
+ exiting: null,
6
+ awaitingExitId: null
7
+ });
8
+ function reconcile(previous, target) {
9
+ if (previous.awaitingExitId !== null) {
10
+ const exiting = previous.exiting;
11
+ if (exiting !== null && target?.id === exiting.id) return {
12
+ ...previous,
13
+ target,
14
+ stack: [...previous.stack, exiting],
15
+ exiting: null,
16
+ awaitingExitId: null
17
+ };
18
+ return {
19
+ ...previous,
20
+ target
21
+ };
22
+ }
23
+ const top = previous.stack[previous.stack.length - 1] ?? null;
24
+ if (top === null) return target === null ? {
25
+ ...previous,
26
+ target: null
27
+ } : {
28
+ ...previous,
29
+ target,
30
+ stack: [target]
31
+ };
32
+ if (target !== null && target.id === top.id) return {
33
+ ...previous,
34
+ target
35
+ };
36
+ if (target !== null && target.routeId.startsWith(`${top.routeId}/`)) return {
37
+ ...previous,
38
+ target,
39
+ stack: [...previous.stack, target]
40
+ };
41
+ if (target !== null && top.routeId.startsWith(`${target.routeId}/`)) return {
42
+ ...previous,
43
+ target,
44
+ stack: previous.stack.slice(0, -1),
45
+ exiting: top,
46
+ awaitingExitId: top.id
47
+ };
48
+ return {
49
+ ...previous,
50
+ target,
51
+ stack: previous.stack.slice(0, -1),
52
+ exiting: top,
53
+ awaitingExitId: top.id
54
+ };
55
+ }
56
+ //#endregion
57
+ export { idleChoreography, reconcile };
58
+
59
+ //# sourceMappingURL=choreography.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"choreography.js","names":[],"sources":["../../src/vkui/choreography.ts"],"sourcesContent":["/**\n * Modal choreography state machine — pure, React-free; the `<Modals>` host\n * in `modals.tsx` drives it and completes exits from the surface's\n * `onClosed`. One step per reconcile: every close is an animated exit whose\n * completion triggers the next step. Route ids are hierarchical\n * (`/view:feed/post/$id/comments`), so the nested/sibling relation is a\n * string prefix check.\n *\n * - a SIBLING retarget (comments → share) closes the displayed sheet first\n * and opens the new one only after the exit completes — never two sibling\n * sheets at once (the user-reported bug from the first spike);\n * - a NESTED open (the target is a child route of the displayed modal)\n * keeps the displayed sheet open and stacks the child ON TOP of it — two\n * open sheets by design; back unwinds the stack one level per entry;\n * - back to the parent modal closes the top and reveals the parent.\n *\n * While an exit is in flight:\n * - retargeting BACK to the exiting sheet CANCELS the exit — VKUI reverses\n * the transition mid-flight (`open` flips back to `true`), so there is no\n * close-then-reopen bounce;\n * - any OTHER target is QUEUED: the state's `target` carries the LATEST\n * router request and the exit's completion re-reconciles toward it.\n * Dropping it would leave the host showing a modal the router no longer\n * matches, desynced until the next navigation.\n */\n\ninterface ChoreographyEntry {\n\t/** Modal id — also the VKUI `nav`. */\n\tid: string;\n\t/** Full route id of the modal route. */\n\trouteId: string;\n}\n\nexport interface ChoreographyState<T extends ChoreographyEntry> {\n\t/** the modal route that should be open on top right now */\n\ttarget: T | null;\n\t/** open entries, bottom → top (nested modals stack) */\n\tstack: T[];\n\t/** the entry animating out (mounted with open=false) */\n\texiting: T | null;\n\t/** the exiting id whose `onClosed` completes the transition */\n\tawaitingExitId: string | null;\n}\n\nexport const idleChoreography = <T extends ChoreographyEntry>(): ChoreographyState<T> => ({\n\ttarget: null,\n\tstack: [],\n\texiting: null,\n\tawaitingExitId: null,\n});\n\nexport function reconcile<T extends ChoreographyEntry>(\n\tprevious: ChoreographyState<T>,\n\ttarget: T | null,\n): ChoreographyState<T> {\n\tif (previous.awaitingExitId !== null) {\n\t\tconst exiting = previous.exiting;\n\t\tif (exiting !== null && target?.id === exiting.id) {\n\t\t\t// retargeting the exiting sheet: CANCEL the exit and restore it\n\t\t\treturn {\n\t\t\t\t...previous,\n\t\t\t\ttarget,\n\t\t\t\tstack: [...previous.stack, exiting],\n\t\t\t\texiting: null,\n\t\t\t\tawaitingExitId: null,\n\t\t\t};\n\t\t}\n\t\t// any other target mid-exit: queue it — the exit's completion\n\t\t// (handleClosed) re-reconciles toward this latest request\n\t\treturn { ...previous, target };\n\t}\n\tconst top = previous.stack[previous.stack.length - 1] ?? null;\n\tif (top === null) {\n\t\treturn target === null\n\t\t\t? { ...previous, target: null }\n\t\t\t: { ...previous, target, stack: [target] };\n\t}\n\tif (target !== null && target.id === top.id) {\n\t\treturn { ...previous, target }; // already on top — settle\n\t}\n\tif (target !== null && target.routeId.startsWith(`${top.routeId}/`)) {\n\t\t// NESTED open: the target is a child route of the displayed modal —\n\t\t// keep it open and stack the child on top\n\t\treturn { ...previous, target, stack: [...previous.stack, target] };\n\t}\n\tif (target !== null && top.routeId.startsWith(`${target.routeId}/`)) {\n\t\t// back to an ANCESTOR modal: close the top, the parent stays open\n\t\treturn {\n\t\t\t...previous,\n\t\t\ttarget,\n\t\t\tstack: previous.stack.slice(0, -1),\n\t\t\texiting: top,\n\t\t\tawaitingExitId: top.id,\n\t\t};\n\t}\n\t// sibling/unrelated modal or close-everything: close the top first —\n\t// the target opens when the exit completes\n\treturn {\n\t\t...previous,\n\t\ttarget,\n\t\tstack: previous.stack.slice(0, -1),\n\t\texiting: top,\n\t\tawaitingExitId: top.id,\n\t};\n}\n"],"mappings":";AA4CA,MAAa,0BAA6E;CACzF,QAAQ;CACR,OAAO,CAAC;CACR,SAAS;CACT,gBAAgB;AACjB;AAEA,SAAgB,UACf,UACA,QACuB;CACvB,IAAI,SAAS,mBAAmB,MAAM;EACrC,MAAM,UAAU,SAAS;EACzB,IAAI,YAAY,QAAQ,QAAQ,OAAO,QAAQ,IAE9C,OAAO;GACN,GAAG;GACH;GACA,OAAO,CAAC,GAAG,SAAS,OAAO,OAAO;GAClC,SAAS;GACT,gBAAgB;EACjB;EAID,OAAO;GAAE,GAAG;GAAU;EAAO;CAC9B;CACA,MAAM,MAAM,SAAS,MAAM,SAAS,MAAM,SAAS,MAAM;CACzD,IAAI,QAAQ,MACX,OAAO,WAAW,OACf;EAAE,GAAG;EAAU,QAAQ;CAAK,IAC5B;EAAE,GAAG;EAAU;EAAQ,OAAO,CAAC,MAAM;CAAE;CAE3C,IAAI,WAAW,QAAQ,OAAO,OAAO,IAAI,IACxC,OAAO;EAAE,GAAG;EAAU;CAAO;CAE9B,IAAI,WAAW,QAAQ,OAAO,QAAQ,WAAW,GAAG,IAAI,QAAQ,EAAE,GAGjE,OAAO;EAAE,GAAG;EAAU;EAAQ,OAAO,CAAC,GAAG,SAAS,OAAO,MAAM;CAAE;CAElE,IAAI,WAAW,QAAQ,IAAI,QAAQ,WAAW,GAAG,OAAO,QAAQ,EAAE,GAEjE,OAAO;EACN,GAAG;EACH;EACA,OAAO,SAAS,MAAM,MAAM,GAAG,EAAE;EACjC,SAAS;EACT,gBAAgB,IAAI;CACrB;CAID,OAAO;EACN,GAAG;EACH;EACA,OAAO,SAAS,MAAM,MAAM,GAAG,EAAE;EACjC,SAAS;EACT,gBAAgB,IAAI;CACrB;AACD"}
@@ -0,0 +1,49 @@
1
+ import { ComponentType, LazyExoticComponent } from "react";
2
+ import { ModalCardProps, ModalPageProps } from "@vkontakte/vkui";
3
+ //#region src/vkui/modals.d.ts
4
+ /**
5
+ * Modal choreography host. The state machine lives in `choreography.ts`
6
+ * (pure — sibling closes first, nested stacks on top, back reveals the
7
+ * parent, retarget-during-exit cancels or queues). This host binds it to
8
+ * VKUI: exit completion is EVENT-DRIVEN — VKUI fires `onClosed` at the
9
+ * transition end and backs it with an internal timer computed from the
10
+ * element's real `transitionDuration` (adapts to platform/theme/reduced-
11
+ * motion — more accurate than any host-side constant). The host
12
+ * `exitDuration` is only a WATCHDOG for the one case VKUI cannot report:
13
+ * the modal unmounting before the exit finishes (its entry vanished from
14
+ * `entries`).
15
+ */
16
+ /** A renderable modal entry component — plain or React.lazy. */
17
+ export type ModalEntryComponent = ComponentType | LazyExoticComponent<ComponentType>;
18
+ export interface ModalEntry {
19
+ /** Full route id of the modal route (e.g. `/item/$id/details`). */
20
+ routeId: string;
21
+ /** Modal id — also the VKUI `nav`. */
22
+ id: string;
23
+ /**
24
+ * The modal content. Its root must be a ModalPageSurface or
25
+ * ModalCardSurface — the host binds nav/open/close choreography to it
26
+ * through context; the component owns every other prop at runtime.
27
+ */
28
+ Component: ModalEntryComponent;
29
+ }
30
+ export interface ModalsProps {
31
+ entries: ModalEntry[];
32
+ /**
33
+ * Watchdog bound for an exit whose `onClosed` never arrives (the modal
34
+ * unmounted mid-animation), ms. Default 420 — well past any VKUI modal
35
+ * exit animation; a healthy `onClosed` lands long before it fires.
36
+ */
37
+ exitDuration?: number;
38
+ }
39
+ /** ModalPage surface for a modal entry — the host owns nav/open/close choreography. */
40
+ export declare function ModalPageSurface({ children, ...surface }: Omit<ModalPageProps, "nav" | "open" | "onClose" | "onClosed" | "keepMounted">): import("react").JSX.Element;
41
+ /**
42
+ * ModalCard surface for a modal entry — same contract as
43
+ * ModalPageSurface, with the card's icon/title/description/actions as
44
+ * ordinary runtime props.
45
+ */
46
+ export declare function ModalCardSurface({ children, ...surface }: Omit<ModalCardProps, "nav" | "open" | "onClose" | "onClosed" | "keepMounted">): import("react").JSX.Element;
47
+ export declare function Modals({ entries, exitDuration }: ModalsProps): import("react").JSX.Element;
48
+ //#endregion
49
+ //# sourceMappingURL=modals.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"modals.d.ts","names":[],"sources":["../../src/vkui/modals.tsx"],"mappings":";;;;;;;;;;;;;;;;YA2BY,sBAAsB,gBAAgB,oBAAoB;iBAErD;;EAEhB;;EAEA;;;;;;EAMA,WAAW;;iBAGK;EAChB,SAAS;;;;;;EAMT;;;wBA0Ce,mBACf,aACG,WACD,KAAK,2FAAwE,IAAA;;;;;;wBAmBhE,mBACf,aACG,WACD,KAAK,2FAAwE,IAAA;wBAchE,SAAS,SAAS,gBAAsB,8BAAW,IAAA"}