@vkma.dev/router 0.0.0-stage → 0.1.0

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 +27 -0
  35. package/dist/preset/app.d.ts.map +1 -0
  36. package/dist/preset/app.js +110 -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 +71 -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 +158 -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 +252 -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 +136 -0
  110. package/src/vkui.ts +17 -0
  111. package/README.md +0 -3
@@ -0,0 +1,116 @@
1
+ import { idleChoreography, reconcile } from "./choreography.js";
2
+ import { useRouterState } from "@tanstack/react-router";
3
+ import { Suspense, createContext, useContext, useEffect, useState } from "react";
4
+ import { ModalCard, ModalPage } from "@vkontakte/vkui";
5
+ import { Fragment, jsx } from "react/jsx-runtime";
6
+ //#region src/vkui/modals.tsx
7
+ const ModalSurfaceContext = createContext(null);
8
+ function useModalSurface() {
9
+ const api = useContext(ModalSurfaceContext);
10
+ if (api === null) throw new Error("[vkma-router] ModalPageSurface/ModalCardSurface must be the root of a modal entry rendered by <Modals>");
11
+ return api;
12
+ }
13
+ /**
14
+ * The host-owned keys VKUI actually accepts. Everything else in the api
15
+ * (`stacked` today, future host keys tomorrow) must NOT reach the spread —
16
+ * VKUI forwards unknown props to the DOM and React warns on booleans
17
+ * ("Received `false` for a non-boolean attribute `stacked`").
18
+ */
19
+ function hostProps(api) {
20
+ const { nav, open, onClose, onClosed } = api;
21
+ return {
22
+ nav,
23
+ open,
24
+ onClose,
25
+ onClosed
26
+ };
27
+ }
28
+ /** ModalPage surface for a modal entry — the host owns nav/open/close choreography. */
29
+ function ModalPageSurface({ children, ...surface }) {
30
+ const api = useModalSurface();
31
+ return /* @__PURE__ */ jsx(ModalPage, {
32
+ ...hostProps(api),
33
+ keepMounted: true,
34
+ disableFocusTrap: api.stacked || void 0,
35
+ ...surface,
36
+ children
37
+ });
38
+ }
39
+ /**
40
+ * ModalCard surface for a modal entry — same contract as
41
+ * ModalPageSurface, with the card's icon/title/description/actions as
42
+ * ordinary runtime props.
43
+ */
44
+ function ModalCardSurface({ children, ...surface }) {
45
+ const api = useModalSurface();
46
+ return /* @__PURE__ */ jsx(ModalCard, {
47
+ ...hostProps(api),
48
+ keepMounted: true,
49
+ disableFocusTrap: api.stacked || void 0,
50
+ ...surface,
51
+ children
52
+ });
53
+ }
54
+ function Modals({ entries, exitDuration = 420 }) {
55
+ const routeIds = new Set(entries.map((entry) => entry.routeId));
56
+ const matchedModalEntry = useRouterState({ select: (state) => {
57
+ for (let i = state.matches.length - 1; i >= 0; i--) {
58
+ const routeId = state.matches[i]?.routeId;
59
+ if (routeId !== void 0 && routeIds.has(routeId)) return entries.find((entry) => entry.routeId === routeId) ?? null;
60
+ }
61
+ return null;
62
+ } });
63
+ const globalModalId = useRouterState({ select: (state) => state.location.search?.modal ?? null });
64
+ const globalEntry = globalModalId !== null ? entries.find((entry) => entry.id === globalModalId) ?? null : null;
65
+ const target = matchedModalEntry ?? globalEntry;
66
+ const [state, setState] = useState(() => reconcile(idleChoreography(), target));
67
+ useEffect(() => {
68
+ setState((previous) => reconcile(previous, target));
69
+ }, [target]);
70
+ const handleClosed = (closedId) => {
71
+ setState((previous) => {
72
+ if (previous.awaitingExitId !== closedId) return previous;
73
+ const settled = {
74
+ ...previous,
75
+ exiting: null,
76
+ awaitingExitId: null
77
+ };
78
+ return reconcile(settled, settled.target);
79
+ });
80
+ };
81
+ useEffect(() => {
82
+ if (state.awaitingExitId === null) return;
83
+ const watchdog = setTimeout(() => handleClosed(state.awaitingExitId), exitDuration * 2);
84
+ return () => clearTimeout(watchdog);
85
+ }, [state.awaitingExitId, exitDuration]);
86
+ const rendered = [];
87
+ state.stack.forEach((entry, index) => {
88
+ rendered.push({
89
+ entry,
90
+ open: true,
91
+ stacked: index < state.stack.length - 1
92
+ });
93
+ });
94
+ if (state.exiting !== null && !state.stack.some((entry) => entry.id === state.exiting?.id)) rendered.push({
95
+ entry: state.exiting,
96
+ open: false,
97
+ stacked: false
98
+ });
99
+ return /* @__PURE__ */ jsx(Fragment, { children: rendered.map(({ entry, open, stacked }) => /* @__PURE__ */ jsx(ModalSurfaceContext.Provider, {
100
+ value: {
101
+ nav: entry.id,
102
+ open,
103
+ stacked,
104
+ onClose: () => window.history.back(),
105
+ onClosed: () => handleClosed(entry.id)
106
+ },
107
+ children: /* @__PURE__ */ jsx(Suspense, {
108
+ fallback: null,
109
+ children: /* @__PURE__ */ jsx(entry.Component, {})
110
+ })
111
+ }, entry.id)) });
112
+ }
113
+ //#endregion
114
+ export { ModalCardSurface, ModalPageSurface, Modals, hostProps };
115
+
116
+ //# sourceMappingURL=modals.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"modals.js","names":[],"sources":["../../src/vkui/modals.tsx"],"sourcesContent":["import { useRouterState } from \"@tanstack/react-router\";\nimport { ModalCard, ModalPage, type ModalCardProps, type ModalPageProps } from \"@vkontakte/vkui\";\nimport {\n\ttype ComponentType,\n\tcreateContext,\n\ttype LazyExoticComponent,\n\tSuspense,\n\tuseContext,\n\tuseEffect,\n\tuseState,\n} from \"react\";\nimport { idleChoreography, reconcile, type ChoreographyState } from \"./choreography.ts\";\n\n/**\n * Modal choreography host. The state machine lives in `choreography.ts`\n * (pure — sibling closes first, nested stacks on top, back reveals the\n * parent, retarget-during-exit cancels or queues). This host binds it to\n * VKUI: exit completion is EVENT-DRIVEN — VKUI fires `onClosed` at the\n * transition end and backs it with an internal timer computed from the\n * element's real `transitionDuration` (adapts to platform/theme/reduced-\n * motion — more accurate than any host-side constant). The host\n * `exitDuration` is only a WATCHDOG for the one case VKUI cannot report:\n * the modal unmounting before the exit finishes (its entry vanished from\n * `entries`).\n */\n\n/** A renderable modal entry component — plain or React.lazy. */\nexport type ModalEntryComponent = ComponentType | LazyExoticComponent<ComponentType>;\n\nexport interface ModalEntry {\n\t/** Full route id of the modal route (e.g. `/item/$id/details`). */\n\trouteId: string;\n\t/** Modal id — also the VKUI `nav`. */\n\tid: string;\n\t/**\n\t * The modal content. Its root must be a ModalPageSurface or\n\t * ModalCardSurface — the host binds nav/open/close choreography to it\n\t * through context; the component owns every other prop at runtime.\n\t */\n\tComponent: ModalEntryComponent;\n}\n\nexport interface ModalsProps {\n\tentries: ModalEntry[];\n\t/**\n\t * Watchdog bound for an exit whose `onClosed` never arrives (the modal\n\t * unmounted mid-animation), ms. Default 420 — well past any VKUI modal\n\t * exit animation; a healthy `onClosed` lands long before it fires.\n\t */\n\texitDuration?: number;\n}\n\n/** Host-bound surface controls, provided to every mounted modal entry. */\ninterface ModalSurfaceApi {\n\tnav: string;\n\topen: boolean;\n\t/** Another modal is stacked on top of this one — suppress the focus trap. */\n\tstacked: boolean;\n\tonClose: () => void;\n\tonClosed: () => void;\n}\n\nconst ModalSurfaceContext = createContext<ModalSurfaceApi | null>(null);\n\nfunction useModalSurface(): ModalSurfaceApi {\n\tconst api = useContext(ModalSurfaceContext);\n\tif (api === null) {\n\t\tthrow new Error(\n\t\t\t\"[vkma-router] ModalPageSurface/ModalCardSurface must be the root of a modal entry rendered by <Modals>\",\n\t\t);\n\t}\n\treturn api;\n}\n\n/**\n * The host-owned keys VKUI actually accepts. Everything else in the api\n * (`stacked` today, future host keys tomorrow) must NOT reach the spread —\n * VKUI forwards unknown props to the DOM and React warns on booleans\n * (\"Received `false` for a non-boolean attribute `stacked`\").\n */\nexport function hostProps(api: ModalSurfaceApi): {\n\tnav: string;\n\topen: boolean;\n\tonClose: () => void;\n\tonClosed: () => void;\n} {\n\tconst { nav, open, onClose, onClosed } = api;\n\treturn { nav, open, onClose, onClosed };\n}\n\n/** ModalPage surface for a modal entry — the host owns nav/open/close choreography. */\nexport function ModalPageSurface({\n\tchildren,\n\t...surface\n}: Omit<ModalPageProps, \"nav\" | \"open\" | \"onClose\" | \"onClosed\" | \"keepMounted\">) {\n\tconst api = useModalSurface();\n\treturn (\n\t\t<ModalPage\n\t\t\t{...hostProps(api)}\n\t\t\tkeepMounted\n\t\t\tdisableFocusTrap={api.stacked || undefined}\n\t\t\t{...surface}\n\t\t>\n\t\t\t{children}\n\t\t</ModalPage>\n\t);\n}\n\n/**\n * ModalCard surface for a modal entry — same contract as\n * ModalPageSurface, with the card's icon/title/description/actions as\n * ordinary runtime props.\n */\nexport function ModalCardSurface({\n\tchildren,\n\t...surface\n}: Omit<ModalCardProps, \"nav\" | \"open\" | \"onClose\" | \"onClosed\" | \"keepMounted\">) {\n\tconst api = useModalSurface();\n\treturn (\n\t\t<ModalCard\n\t\t\t{...hostProps(api)}\n\t\t\tkeepMounted\n\t\t\tdisableFocusTrap={api.stacked || undefined}\n\t\t\t{...surface}\n\t\t>\n\t\t\t{children}\n\t\t</ModalCard>\n\t);\n}\n\nexport function Modals({ entries, exitDuration = 420 }: ModalsProps) {\n\tconst routeIds = new Set(entries.map(entry => entry.routeId));\n\tconst matchedModalEntry = useRouterState({\n\t\tselect: state => {\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 && routeIds.has(routeId)) {\n\t\t\t\t\treturn entries.find(entry => entry.routeId === routeId) ?? null;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t});\n\tconst globalModalId = useRouterState({\n\t\tselect: state => (state.location.search as { modal?: string } | undefined)?.modal ?? null,\n\t});\n\tconst globalEntry =\n\t\tglobalModalId !== null ? (entries.find(entry => entry.id === globalModalId) ?? null) : null;\n\tconst target = matchedModalEntry ?? globalEntry;\n\n\tconst [state, setState] = useState<ChoreographyState<ModalEntry>>(() =>\n\t\treconcile(idleChoreography<ModalEntry>(), target),\n\t);\n\n\t// choreograph target changes (see reconcile)\n\tuseEffect(() => {\n\t\tsetState(previous => reconcile(previous, target));\n\t}, [target]);\n\n\tconst handleClosed = (closedId: string): void => {\n\t\tsetState(previous => {\n\t\t\tif (previous.awaitingExitId !== closedId) {\n\t\t\t\treturn previous; // stale or unexpected callback\n\t\t\t}\n\t\t\tconst settled: ChoreographyState<ModalEntry> = {\n\t\t\t\t...previous,\n\t\t\t\texiting: null,\n\t\t\t\tawaitingExitId: null,\n\t\t\t};\n\t\t\treturn reconcile(settled, settled.target);\n\t\t});\n\t};\n\n\t// watchdog for the lost-onClosed case (unmount mid-exit); the dep set\n\t// re-arms it on every new exit and cleans up on completion/unmount\n\tuseEffect(() => {\n\t\tif (state.awaitingExitId === null) {\n\t\t\treturn;\n\t\t}\n\t\tconst watchdog = setTimeout(\n\t\t\t() => handleClosed(state.awaitingExitId as string),\n\t\t\texitDuration * 2,\n\t\t);\n\t\treturn () => clearTimeout(watchdog);\n\t}, [state.awaitingExitId, exitDuration]);\n\n\t// render the open stack (bottom → top) plus the exiting sheet\n\tconst rendered: Array<{ entry: ModalEntry; open: boolean; stacked: boolean }> = [];\n\tstate.stack.forEach((entry, index) => {\n\t\trendered.push({ entry, open: true, stacked: index < state.stack.length - 1 });\n\t});\n\tif (state.exiting !== null && !state.stack.some(entry => entry.id === state.exiting?.id)) {\n\t\trendered.push({ entry: state.exiting, open: false, stacked: false });\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t{rendered.map(({ entry, open, stacked }) => (\n\t\t\t\t<ModalSurfaceContext.Provider\n\t\t\t\t\tkey={entry.id}\n\t\t\t\t\tvalue={{\n\t\t\t\t\t\tnav: entry.id,\n\t\t\t\t\t\topen,\n\t\t\t\t\t\tstacked,\n\t\t\t\t\t\tonClose: () => window.history.back(),\n\t\t\t\t\t\tonClosed: () => handleClosed(entry.id),\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<Suspense fallback={null}>\n\t\t\t\t\t\t<entry.Component />\n\t\t\t\t\t</Suspense>\n\t\t\t\t</ModalSurfaceContext.Provider>\n\t\t\t))}\n\t\t</>\n\t);\n}\n"],"mappings":"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"}
@@ -0,0 +1,9 @@
1
+ //#region src/vkui/not-found.d.ts
2
+ /**
3
+ * Default 404 screen (parity with the old router's DefaultNotFound): wire it
4
+ * as `defaultNotFoundComponent` in `createRouter` — TanStack otherwise
5
+ * renders a bare `<p>Not Found</p>` and warns.
6
+ */
7
+ export declare function NotFound(): React.ReactNode;
8
+ //#endregion
9
+ //# sourceMappingURL=not-found.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"not-found.d.ts","names":[],"sources":["../../src/vkui/not-found.tsx"],"mappings":";;;;;;wBAOgB,YAAY,MAAM"}
@@ -0,0 +1,23 @@
1
+ import { Button, Placeholder } from "@vkontakte/vkui";
2
+ import { jsx } from "react/jsx-runtime";
3
+ //#region src/vkui/not-found.tsx
4
+ /**
5
+ * Default 404 screen (parity with the old router's DefaultNotFound): wire it
6
+ * as `defaultNotFoundComponent` in `createRouter` — TanStack otherwise
7
+ * renders a bare `<p>Not Found</p>` and warns.
8
+ */
9
+ function NotFound() {
10
+ return /* @__PURE__ */ jsx(Placeholder, {
11
+ title: "Страница не найдена",
12
+ action: /* @__PURE__ */ jsx(Button, {
13
+ size: "l",
14
+ onClick: () => window.history.back(),
15
+ children: "Назад"
16
+ }),
17
+ children: "Такой страницы не существует."
18
+ });
19
+ }
20
+ //#endregion
21
+ export { NotFound };
22
+
23
+ //# sourceMappingURL=not-found.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"not-found.js","names":[],"sources":["../../src/vkui/not-found.tsx"],"sourcesContent":["import { Button, Placeholder } from \"@vkontakte/vkui\";\n\n/**\n * Default 404 screen (parity with the old router's DefaultNotFound): wire it\n * as `defaultNotFoundComponent` in `createRouter` — TanStack otherwise\n * renders a bare `<p>Not Found</p>` and warns.\n */\nexport function NotFound(): React.ReactNode {\n\treturn (\n\t\t<Placeholder\n\t\t\ttitle=\"Страница не найдена\"\n\t\t\taction={\n\t\t\t\t<Button size=\"l\" onClick={() => window.history.back()}>\n\t\t\t\t\tНазад\n\t\t\t\t</Button>\n\t\t\t}\n\t\t>\n\t\t\tТакой страницы не существует.\n\t\t</Placeholder>\n\t);\n}\n"],"mappings":";;;;;;;;AAOA,SAAgB,WAA4B;CAC3C,OACC,oBAAC,aAAD;EACC,OAAM;EACN,QACC,oBAAC,QAAD;GAAQ,MAAK;GAAI,eAAe,OAAO,QAAQ,KAAK;GAAG,UAAA;EAE/C,CAAA;EAET,UAAA;CAEY,CAAA;AAEf"}
@@ -0,0 +1,34 @@
1
+ import { ComponentType } from "react";
2
+ //#region src/vkui/popouts.d.ts
3
+ /**
4
+ * Popouts are transient overlays, not routes: `history.state` holds
5
+ * `{ popout: id, props }`, so the native/system back closes them, and
6
+ * back/forward restores both the id and the props. Components come from
7
+ * the registry (per-panel and/or global — see the preset builders);
8
+ * `onClose` is injected by the host.
9
+ */
10
+ export interface PopoutEntry {
11
+ id: string;
12
+ Component: ComponentType<{
13
+ onClose: () => void;
14
+ }>;
15
+ /**
16
+ * The owning panel key (`${view}/${panel}`) for a panel-local popout —
17
+ * it resolves only while that panel is the deepest matched one.
18
+ * Undefined = global (resolves from any panel).
19
+ */
20
+ panel?: string;
21
+ }
22
+ export declare function usePopoutState(): {
23
+ id: string;
24
+ props?: Record<string, unknown>;
25
+ } | undefined;
26
+ /** `showPopout(id, props)` — pushes a popout entry; replaces if one is open. */
27
+ export declare function useShowPopout(): (id: string, props?: Record<string, unknown>) => void;
28
+ /** `hidePopout()` — unwinds the popout entry; on the first entry, strips in place. */
29
+ export declare function useHidePopout(): () => void;
30
+ export declare function Popouts({ entries }: {
31
+ entries: PopoutEntry[];
32
+ }): import("react").JSX.Element | null;
33
+ //#endregion
34
+ //# sourceMappingURL=popouts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"popouts.d.ts","names":[],"sources":["../../src/vkui/popouts.tsx"],"mappings":";;;;;;;;;iBAaiB;EAChB;EACA,WAAW;IAAgB;;;;;;;EAM3B;;wBAGe;EAAoB;EAAY,QAAQ;;;wBAKxC,kBAAkB,YAAY,QAAQ;;wBAiBtC;wBAuCA,UAAU;EAAa,SAAS;oBAAe,IAAA"}
@@ -0,0 +1,69 @@
1
+ import { POPOUT_STATE_KEY, readPopoutState } from "../core/state-keys.js";
2
+ import { looseRouteOf } from "../core/loose-route.js";
3
+ import { useRouter, useRouterState } from "@tanstack/react-router";
4
+ import { jsx } from "react/jsx-runtime";
5
+ //#region src/vkui/popouts.tsx
6
+ function usePopoutState() {
7
+ return useRouterState({ select: (state) => readPopoutState(state.location.state) });
8
+ }
9
+ /** `showPopout(id, props)` — pushes a popout entry; replaces if one is open. */
10
+ function useShowPopout() {
11
+ const router = useRouter();
12
+ const isOpen = usePopoutState() !== void 0;
13
+ return (id, props) => {
14
+ router.navigate({
15
+ href: router.state.location.href,
16
+ state: (previous) => ({
17
+ ...previous ?? {},
18
+ [POPOUT_STATE_KEY]: {
19
+ id,
20
+ props
21
+ }
22
+ }),
23
+ replace: isOpen
24
+ });
25
+ };
26
+ }
27
+ /** `hidePopout()` — unwinds the popout entry; on the first entry, strips in place. */
28
+ function useHidePopout() {
29
+ const router = useRouter();
30
+ return () => {
31
+ if (router.history.canGoBack()) {
32
+ router.history.back();
33
+ return;
34
+ }
35
+ const state = { ...router.state.location.state };
36
+ delete state[POPOUT_STATE_KEY];
37
+ router.navigate({
38
+ href: router.state.location.href,
39
+ replace: true,
40
+ state
41
+ });
42
+ };
43
+ }
44
+ /** The deepest matched panel's registry key (`${view}/${panel}`), or null. */
45
+ function useActivePanelKey() {
46
+ const router = useRouter();
47
+ return useRouterState({ select: (state) => {
48
+ for (let i = state.matches.length - 1; i >= 0; i--) {
49
+ const routeId = state.matches[i]?.routeId;
50
+ if (routeId === void 0) continue;
51
+ const panel = looseRouteOf(router, routeId)?.options.staticData?.panel;
52
+ if (panel !== void 0) return panel;
53
+ }
54
+ return null;
55
+ } });
56
+ }
57
+ function Popouts({ entries }) {
58
+ const popout = usePopoutState();
59
+ const hide = useHidePopout();
60
+ const activePanelKey = useActivePanelKey();
61
+ if (popout === void 0) return null;
62
+ const entry = entries.find((candidate) => candidate.panel !== void 0 && candidate.panel === activePanelKey && candidate.id === popout.id) ?? entries.find((candidate) => candidate.panel === void 0 && candidate.id === popout.id);
63
+ if (!entry) return null;
64
+ return /* @__PURE__ */ jsx(entry.Component, { onClose: hide });
65
+ }
66
+ //#endregion
67
+ export { Popouts, useHidePopout, usePopoutState, useShowPopout };
68
+
69
+ //# sourceMappingURL=popouts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"popouts.js","names":[],"sources":["../../src/vkui/popouts.tsx"],"sourcesContent":["import { useRouter, useRouterState } from \"@tanstack/react-router\";\nimport { type ComponentType } from \"react\";\nimport { looseRouteOf } from \"../core/loose-route.ts\";\nimport { POPOUT_STATE_KEY, readPopoutState } from \"../core/state-keys.ts\";\n\n/**\n * Popouts are transient overlays, not routes: `history.state` holds\n * `{ popout: id, props }`, so the native/system back closes them, and\n * back/forward restores both the id and the props. Components come from\n * the registry (per-panel and/or global — see the preset builders);\n * `onClose` is injected by the host.\n */\n\nexport interface PopoutEntry {\n\tid: string;\n\tComponent: ComponentType<{ onClose: () => void }>;\n\t/**\n\t * The owning panel key (`${view}/${panel}`) for a panel-local popout —\n\t * it resolves only while that panel is the deepest matched one.\n\t * Undefined = global (resolves from any panel).\n\t */\n\tpanel?: string;\n}\n\nexport function usePopoutState(): { id: string; props?: Record<string, unknown> } | undefined {\n\treturn useRouterState({ select: state => readPopoutState(state.location.state) });\n}\n\n/** `showPopout(id, props)` — pushes a popout entry; replaces if one is open. */\nexport function useShowPopout(): (id: string, props?: Record<string, unknown>) => void {\n\tconst router = useRouter();\n\tconst isOpen = usePopoutState() !== undefined;\n\treturn (id, props) => {\n\t\tvoid router.navigate({\n\t\t\thref: router.state.location.href,\n\t\t\tstate: (previous: unknown) =>\n\t\t\t\t({\n\t\t\t\t\t...((previous ?? {}) as Record<string, unknown>),\n\t\t\t\t\t[POPOUT_STATE_KEY]: { id, props },\n\t\t\t\t}) as never,\n\t\t\treplace: isOpen,\n\t\t});\n\t};\n}\n\n/** `hidePopout()` — unwinds the popout entry; on the first entry, strips in place. */\nexport function useHidePopout(): () => void {\n\tconst router = useRouter();\n\treturn () => {\n\t\tif (router.history.canGoBack()) {\n\t\t\trouter.history.back();\n\t\t\treturn;\n\t\t}\n\t\tconst state = {\n\t\t\t...(router.state.location.state as unknown as Record<string, unknown>),\n\t\t};\n\t\tdelete state[POPOUT_STATE_KEY];\n\t\tvoid router.navigate({\n\t\t\thref: router.state.location.href,\n\t\t\treplace: true,\n\t\t\tstate,\n\t\t});\n\t};\n}\n\n/** The deepest matched panel's registry key (`${view}/${panel}`), or null. */\nfunction useActivePanelKey(): 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\tconst panel = looseRouteOf(router, routeId)?.options.staticData?.panel;\n\t\t\t\tif (panel !== undefined) {\n\t\t\t\t\treturn panel;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn null;\n\t\t},\n\t});\n}\n\nexport function Popouts({ entries }: { entries: PopoutEntry[] }) {\n\tconst popout = usePopoutState();\n\tconst hide = useHidePopout();\n\tconst activePanelKey = useActivePanelKey();\n\tif (popout === undefined) {\n\t\treturn null;\n\t}\n\t// resolution local → global: the current panel's own popouts shadow the\n\t// global registry\n\tconst entry =\n\t\tentries.find(\n\t\t\tcandidate =>\n\t\t\t\tcandidate.panel !== undefined &&\n\t\t\t\tcandidate.panel === activePanelKey &&\n\t\t\t\tcandidate.id === popout.id,\n\t\t) ?? entries.find(candidate => candidate.panel === undefined && candidate.id === popout.id);\n\tif (!entry) {\n\t\treturn null;\n\t}\n\treturn <entry.Component onClose={hide} />;\n}\n"],"mappings":";;;;;AAwBA,SAAgB,iBAA8E;CAC7F,OAAO,eAAe,EAAE,SAAQ,UAAS,gBAAgB,MAAM,SAAS,KAAK,EAAE,CAAC;AACjF;;AAGA,SAAgB,gBAAuE;CACtF,MAAM,SAAS,UAAU;CACzB,MAAM,SAAS,eAAe,MAAM,KAAA;CACpC,QAAQ,IAAI,UAAU;EACrB,OAAY,SAAS;GACpB,MAAM,OAAO,MAAM,SAAS;GAC5B,QAAQ,cACN;IACA,GAAK,YAAY,CAAC;KACjB,mBAAmB;KAAE;KAAI;IAAM;GACjC;GACD,SAAS;EACV,CAAC;CACF;AACD;;AAGA,SAAgB,gBAA4B;CAC3C,MAAM,SAAS,UAAU;CACzB,aAAa;EACZ,IAAI,OAAO,QAAQ,UAAU,GAAG;GAC/B,OAAO,QAAQ,KAAK;GACpB;EACD;EACA,MAAM,QAAQ,EACb,GAAI,OAAO,MAAM,SAAS,MAC3B;EACA,OAAO,MAAM;EACb,OAAY,SAAS;GACpB,MAAM,OAAO,MAAM,SAAS;GAC5B,SAAS;GACT;EACD,CAAC;CACF;AACD;;AAGA,SAAS,oBAAmC;CAC3C,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,MAAM,QAAQ,aAAa,QAAQ,OAAO,CAAC,EAAE,QAAQ,YAAY;GACjE,IAAI,UAAU,KAAA,GACb,OAAO;EAET;EACA,OAAO;CACR,EACD,CAAC;AACF;AAEA,SAAgB,QAAQ,EAAE,WAAuC;CAChE,MAAM,SAAS,eAAe;CAC9B,MAAM,OAAO,cAAc;CAC3B,MAAM,iBAAiB,kBAAkB;CACzC,IAAI,WAAW,KAAA,GACd,OAAO;CAIR,MAAM,QACL,QAAQ,MACP,cACC,UAAU,UAAU,KAAA,KACpB,UAAU,UAAU,kBACpB,UAAU,OAAO,OAAO,EAC1B,KAAK,QAAQ,MAAK,cAAa,UAAU,UAAU,KAAA,KAAa,UAAU,OAAO,OAAO,EAAE;CAC3F,IAAI,CAAC,OACJ,OAAO;CAER,OAAO,oBAAC,MAAM,WAAP,EAAiB,SAAS,KAAO,CAAA;AACzC"}
@@ -0,0 +1,26 @@
1
+ import { AnyRoute } from "@tanstack/react-router";
2
+ //#region src/vkui/vk-view.d.ts
3
+ export declare function panelIdOf(route: AnyRoute): string;
4
+ export interface VkViewProps {
5
+ /** The pathless view layout route created by the preset. */
6
+ view: AnyRoute;
7
+ /** VKUI nav id of the View. Defaults to the route id. */
8
+ nav?: string;
9
+ /** Native swipe-back pop; defaults to `window.history.back()`. */
10
+ onSwipeBack?: () => void;
11
+ }
12
+ /**
13
+ * Static-children view: every panel route renders as an always-present JSX
14
+ * child of VKUI <View>; the router only feeds `activePanel` (and `history`
15
+ * for the iOS swipe-back). VKUI hides inactive panels itself and infers
16
+ * the animation direction from the children order — a plain <Outlet>
17
+ * breaks both. A lazy panel's `options.component` is undefined until its
18
+ * chunk resolves (TanStack merges loaded options in place), so the panel
19
+ * renders a spinner fallback until then. For the same reason TanStack's
20
+ * Outlet-driven `pendingComponent` swap never happens on its own — while
21
+ * the panel's match is pending (loader/beforeLoad), the route's
22
+ * `pendingComponent` renders instead of the component.
23
+ */
24
+ export declare function VkView({ view, nav, onSwipeBack }: VkViewProps): import("react").JSX.Element;
25
+ //#endregion
26
+ //# sourceMappingURL=vk-view.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vk-view.d.ts","names":[],"sources":["../../src/vkui/vk-view.tsx"],"mappings":";;wBAUgB,UAAU,OAAO;iBAyBhB;;EAEhB,MAAM;;EAEN;;EAEA;;;;;;;;;;;;;;wBAee,SAAS,MAAM,KAAK,eAAe,8BAAW,IAAA"}
@@ -0,0 +1,71 @@
1
+ import { usePanelsHistory } from "../react/hooks.js";
2
+ import { useRouterState } from "@tanstack/react-router";
3
+ import { Panel, Spinner, View } from "@vkontakte/vkui";
4
+ import { jsx } from "react/jsx-runtime";
5
+ //#region src/vkui/vk-view.tsx
6
+ function panelIdOf(route) {
7
+ return route.options.staticData?.panel ?? "";
8
+ }
9
+ function deepestPanelMatchId(view, matchedRouteIds) {
10
+ const panelIds = new Set(view.children.map((child) => child.id));
11
+ for (let i = matchedRouteIds.length - 1; i >= 0; i--) {
12
+ const id = matchedRouteIds[i];
13
+ if (id !== void 0 && panelIds.has(id)) return id;
14
+ }
15
+ return view.children[0]?.id ?? "";
16
+ }
17
+ function useMatchSummaries() {
18
+ return useRouterState({ select: (state) => state.matches.map((match) => `${match.routeId}#${match.status}`).join("|") });
19
+ }
20
+ /**
21
+ * Static-children view: every panel route renders as an always-present JSX
22
+ * child of VKUI <View>; the router only feeds `activePanel` (and `history`
23
+ * for the iOS swipe-back). VKUI hides inactive panels itself and infers
24
+ * the animation direction from the children order — a plain <Outlet>
25
+ * breaks both. A lazy panel's `options.component` is undefined until its
26
+ * chunk resolves (TanStack merges loaded options in place), so the panel
27
+ * renders a spinner fallback until then. For the same reason TanStack's
28
+ * Outlet-driven `pendingComponent` swap never happens on its own — while
29
+ * the panel's match is pending (loader/beforeLoad), the route's
30
+ * `pendingComponent` renders instead of the component.
31
+ */
32
+ function VkView({ view, nav, onSwipeBack }) {
33
+ useMatchSummaries();
34
+ const matchedRouteIds = useRouterState({ select: (state) => state.matches.map((match) => match.routeId) });
35
+ const pendingRouteIds = useRouterState({ select: (state) => state.matches.filter((match) => match.status === "pending").map((match) => String(match.routeId)) });
36
+ const notFoundRouteIds = useRouterState({ select: (state) => state.matches.filter((match) => match.status === "notFound").map((match) => String(match.routeId)) });
37
+ const activeRouteId = deepestPanelMatchId(view, matchedRouteIds);
38
+ const activeChild = view.children.find((child) => child.id === activeRouteId) ?? view.children[0];
39
+ const activePanel = activeChild ? panelIdOf(activeChild) : "";
40
+ const history = usePanelsHistory(activePanel);
41
+ return /* @__PURE__ */ jsx(View, {
42
+ nav: nav ?? view.id,
43
+ activePanel,
44
+ history,
45
+ onSwipeBack: onSwipeBack ?? (() => window.history.back()),
46
+ children: view.children.map((child) => {
47
+ const id = panelIdOf(child);
48
+ const Component = child.options.component;
49
+ const Pending = child.options.pendingComponent;
50
+ const isPending = pendingRouteIds.includes(String(child.id));
51
+ const isNotFound = notFoundRouteIds.includes(String(child.id));
52
+ return /* @__PURE__ */ jsx(Panel, {
53
+ nav: id,
54
+ children: isPending ? Pending !== void 0 ? /* @__PURE__ */ jsx(Pending, {}) : /* @__PURE__ */ jsx(PanelSpinnerFallback, {}) : isNotFound ? /* @__PURE__ */ jsx(PanelSpinnerFallback, {}) : Component ? /* @__PURE__ */ jsx(Component, {}) : /* @__PURE__ */ jsx(PanelSpinnerFallback, {})
55
+ }, id);
56
+ })
57
+ });
58
+ }
59
+ function PanelSpinnerFallback() {
60
+ return /* @__PURE__ */ jsx("div", {
61
+ style: {
62
+ paddingTop: 64,
63
+ textAlign: "center"
64
+ },
65
+ children: /* @__PURE__ */ jsx(Spinner, { size: "l" })
66
+ });
67
+ }
68
+ //#endregion
69
+ export { VkView, panelIdOf };
70
+
71
+ //# sourceMappingURL=vk-view.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vk-view.js","names":[],"sources":["../../src/vkui/vk-view.tsx"],"sourcesContent":["import type { AnyRoute } from \"@tanstack/react-router\";\nimport { useRouterState } from \"@tanstack/react-router\";\nimport { Panel, Spinner, View } from \"@vkontakte/vkui\";\nimport { type ComponentType } from \"react\";\nimport { usePanelsHistory } from \"../react/hooks.ts\";\n\nexport interface PanelStaticData {\n\tpanel: string;\n}\n\nexport function panelIdOf(route: AnyRoute): string {\n\treturn (route.options.staticData as PanelStaticData | undefined)?.panel ?? \"\";\n}\n\nfunction deepestPanelMatchId(view: AnyRoute, matchedRouteIds: string[]): string {\n\tconst panelIds = new Set(view.children.map((child: AnyRoute) => child.id));\n\tfor (let i = matchedRouteIds.length - 1; i >= 0; i--) {\n\t\tconst id = matchedRouteIds[i];\n\t\tif (id !== undefined && panelIds.has(id)) {\n\t\t\treturn id;\n\t\t}\n\t}\n\treturn view.children[0]?.id ?? \"\";\n}\n\nfunction useMatchSummaries(): string {\n\treturn useRouterState({\n\t\tselect: state =>\n\t\t\t// routeId + status: a lazy panel's chunk resolution flips the match\n\t\t\t// status, which must re-render the static children (the resolved\n\t\t\t// component merges into route.options at that point)\n\t\t\tstate.matches.map(match => `${match.routeId}#${match.status}`).join(\"|\"),\n\t});\n}\n\nexport interface VkViewProps {\n\t/** The pathless view layout route created by the preset. */\n\tview: AnyRoute;\n\t/** VKUI nav id of the View. Defaults to the route id. */\n\tnav?: string;\n\t/** Native swipe-back pop; defaults to `window.history.back()`. */\n\tonSwipeBack?: () => void;\n}\n\n/**\n * Static-children view: every panel route renders as an always-present JSX\n * child of VKUI <View>; the router only feeds `activePanel` (and `history`\n * for the iOS swipe-back). VKUI hides inactive panels itself and infers\n * the animation direction from the children order — a plain <Outlet>\n * breaks both. A lazy panel's `options.component` is undefined until its\n * chunk resolves (TanStack merges loaded options in place), so the panel\n * renders a spinner fallback until then. For the same reason TanStack's\n * Outlet-driven `pendingComponent` swap never happens on its own — while\n * the panel's match is pending (loader/beforeLoad), the route's\n * `pendingComponent` renders instead of the component.\n */\nexport function VkView({ view, nav, onSwipeBack }: VkViewProps) {\n\tuseMatchSummaries();\n\tconst matchedRouteIds = useRouterState({\n\t\tselect: state => state.matches.map(match => match.routeId),\n\t});\n\t// structurally-compared array — TanStack keeps the reference stable when\n\t// the selection is deep-equal. TanStack's Outlet would swap in the\n\t// route's pendingComponent itself, but static children render the\n\t// component directly, so the swap is ours to make while the loader runs\n\tconst pendingRouteIds = useRouterState({\n\t\tselect: state =>\n\t\t\tstate.matches\n\t\t\t\t.filter(match => (match as { status?: string }).status === \"pending\")\n\t\t\t\t.map(match => String(match.routeId)),\n\t});\n\t// a notFound match (notFound() from beforeLoad/validateSearch) must NOT\n\t// render its panel component: the component's data guards assume a valid\n\t// match, and the shell renders the NotFound screen for this navigation —\n\t// the panel itself stays invisible. Without the gate the component would\n\t// throw in a transient render and dirty the console on every cold 404\n\tconst notFoundRouteIds = useRouterState({\n\t\tselect: state =>\n\t\t\tstate.matches\n\t\t\t\t.filter(match => (match as { status?: string }).status === \"notFound\")\n\t\t\t\t.map(match => String(match.routeId)),\n\t});\n\tconst activeRouteId = deepestPanelMatchId(view, matchedRouteIds);\n\tconst activeChild =\n\t\tview.children.find((child: AnyRoute) => child.id === activeRouteId) ?? view.children[0];\n\tconst activePanel = activeChild ? panelIdOf(activeChild) : \"\";\n\n\tconst history = usePanelsHistory(activePanel);\n\n\treturn (\n\t\t<View\n\t\t\tnav={nav ?? view.id}\n\t\t\tactivePanel={activePanel}\n\t\t\thistory={history}\n\t\t\tonSwipeBack={onSwipeBack ?? (() => window.history.back())}\n\t\t>\n\t\t\t{view.children.map((child: AnyRoute) => {\n\t\t\t\tconst id = panelIdOf(child);\n\t\t\t\tconst Component = child.options.component as ComponentType | undefined;\n\t\t\t\tconst Pending = child.options.pendingComponent as ComponentType | undefined;\n\t\t\t\tconst isPending = pendingRouteIds.includes(String(child.id));\n\t\t\t\tconst isNotFound = notFoundRouteIds.includes(String(child.id));\n\t\t\t\t// pending without a pendingComponent renders the FALLBACK, never\n\t\t\t\t// the component: a lazy chunk may have already merged its\n\t\t\t\t// component while the match is still pending (or parked on a\n\t\t\t\t// notFound boundary up the tree) — the component's data guards\n\t\t\t\t// assume a resolvable match and would throw in a hidden panel\n\t\t\t\treturn (\n\t\t\t\t\t<Panel key={id} nav={id}>\n\t\t\t\t\t\t{isPending ? (\n\t\t\t\t\t\t\tPending !== undefined ? (\n\t\t\t\t\t\t\t\t<Pending />\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<PanelSpinnerFallback />\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t) : isNotFound ? (\n\t\t\t\t\t\t\t<PanelSpinnerFallback />\n\t\t\t\t\t\t) : Component ? (\n\t\t\t\t\t\t\t<Component />\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<PanelSpinnerFallback />\n\t\t\t\t\t\t)}\n\t\t\t\t\t</Panel>\n\t\t\t\t);\n\t\t\t})}\n\t\t</View>\n\t);\n}\n\nfunction PanelSpinnerFallback() {\n\treturn (\n\t\t<div style={{ paddingTop: 64, textAlign: \"center\" }}>\n\t\t\t<Spinner size=\"l\" />\n\t\t</div>\n\t);\n}\n"],"mappings":";;;;;AAUA,SAAgB,UAAU,OAAyB;CAClD,OAAQ,MAAM,QAAQ,YAA4C,SAAS;AAC5E;AAEA,SAAS,oBAAoB,MAAgB,iBAAmC;CAC/E,MAAM,WAAW,IAAI,IAAI,KAAK,SAAS,KAAK,UAAoB,MAAM,EAAE,CAAC;CACzE,KAAK,IAAI,IAAI,gBAAgB,SAAS,GAAG,KAAK,GAAG,KAAK;EACrD,MAAM,KAAK,gBAAgB;EAC3B,IAAI,OAAO,KAAA,KAAa,SAAS,IAAI,EAAE,GACtC,OAAO;CAET;CACA,OAAO,KAAK,SAAS,EAAE,EAAE,MAAM;AAChC;AAEA,SAAS,oBAA4B;CACpC,OAAO,eAAe,EACrB,SAAQ,UAIP,MAAM,QAAQ,KAAI,UAAS,GAAG,MAAM,QAAQ,GAAG,MAAM,QAAQ,CAAC,CAAC,KAAK,GAAG,EACzE,CAAC;AACF;;;;;;;;;;;;;AAuBA,SAAgB,OAAO,EAAE,MAAM,KAAK,eAA4B;CAC/D,kBAAkB;CAClB,MAAM,kBAAkB,eAAe,EACtC,SAAQ,UAAS,MAAM,QAAQ,KAAI,UAAS,MAAM,OAAO,EAC1D,CAAC;CAKD,MAAM,kBAAkB,eAAe,EACtC,SAAQ,UACP,MAAM,QACJ,QAAO,UAAU,MAA8B,WAAW,SAAS,CAAC,CACpE,KAAI,UAAS,OAAO,MAAM,OAAO,CAAC,EACtC,CAAC;CAMD,MAAM,mBAAmB,eAAe,EACvC,SAAQ,UACP,MAAM,QACJ,QAAO,UAAU,MAA8B,WAAW,UAAU,CAAC,CACrE,KAAI,UAAS,OAAO,MAAM,OAAO,CAAC,EACtC,CAAC;CACD,MAAM,gBAAgB,oBAAoB,MAAM,eAAe;CAC/D,MAAM,cACL,KAAK,SAAS,MAAM,UAAoB,MAAM,OAAO,aAAa,KAAK,KAAK,SAAS;CACtF,MAAM,cAAc,cAAc,UAAU,WAAW,IAAI;CAE3D,MAAM,UAAU,iBAAiB,WAAW;CAE5C,OACC,oBAAC,MAAD;EACC,KAAK,OAAO,KAAK;EACJ;EACJ;EACT,aAAa,sBAAsB,OAAO,QAAQ,KAAK;EAEtD,UAAA,KAAK,SAAS,KAAK,UAAoB;GACvC,MAAM,KAAK,UAAU,KAAK;GAC1B,MAAM,YAAY,MAAM,QAAQ;GAChC,MAAM,UAAU,MAAM,QAAQ;GAC9B,MAAM,YAAY,gBAAgB,SAAS,OAAO,MAAM,EAAE,CAAC;GAC3D,MAAM,aAAa,iBAAiB,SAAS,OAAO,MAAM,EAAE,CAAC;GAM7D,OACC,oBAAC,OAAD;IAAgB,KAAK;IACnB,UAAA,YACA,YAAY,KAAA,IACX,oBAAC,SAAD,CAAU,CAAA,IAEV,oBAAC,sBAAD,CAAuB,CAAA,IAErB,aACH,oBAAC,sBAAD,CAAuB,CAAA,IACpB,YACH,oBAAC,WAAD,CAAY,CAAA,IAEZ,oBAAC,sBAAD,CAAuB,CAAA;GAElB,GAdK,EAcL;EAET,CAAC;CACI,CAAA;AAER;AAEA,SAAS,uBAAuB;CAC/B,OACC,oBAAC,OAAD;EAAK,OAAO;GAAE,YAAY;GAAI,WAAW;EAAS;EACjD,UAAA,oBAAC,SAAD,EAAS,MAAK,IAAK,CAAA;CACf,CAAA;AAEP"}
package/dist/vkui.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { ModalCardSurface, ModalEntry, ModalEntryComponent, ModalPageSurface, Modals, ModalsProps } from "./vkui/modals.js";
2
+ import { PopoutEntry, Popouts, useHidePopout, usePopoutState, useShowPopout } from "./vkui/popouts.js";
3
+ import { VkView, VkViewProps, panelIdOf } from "./vkui/vk-view.js";
4
+ import { NotFound } from "./vkui/not-found.js";
5
+ export { ModalCardSurface, type ModalEntry, type ModalEntryComponent, ModalPageSurface, Modals, type ModalsProps, NotFound, type PopoutEntry, Popouts, VkView, type VkViewProps, panelIdOf, useHidePopout, usePopoutState, useShowPopout };
package/dist/vkui.js ADDED
@@ -0,0 +1,5 @@
1
+ import { VkView, panelIdOf } from "./vkui/vk-view.js";
2
+ import { ModalCardSurface, ModalPageSurface, Modals } from "./vkui/modals.js";
3
+ import { Popouts, useHidePopout, usePopoutState, useShowPopout } from "./vkui/popouts.js";
4
+ import { NotFound } from "./vkui/not-found.js";
5
+ export { ModalCardSurface, ModalPageSurface, Modals, NotFound, Popouts, VkView, panelIdOf, useHidePopout, usePopoutState, useShowPopout };
package/package.json CHANGED
@@ -1,6 +1,73 @@
1
1
  {
2
2
  "name": "@vkma.dev/router",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "VK Mini Apps router on top of TanStack Router — VKUI views/panels/modals/popouts, VK history and bridge sync",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "engines": {
11
+ "node": ">=22"
12
+ },
13
+ "sideEffects": false,
14
+ "main": "./dist/index.js",
15
+ "types": "./dist/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js"
20
+ },
21
+ "./react": {
22
+ "types": "./dist/react.d.ts",
23
+ "import": "./dist/react.js"
24
+ },
25
+ "./vkui": {
26
+ "types": "./dist/vkui.d.ts",
27
+ "import": "./dist/vkui.js"
28
+ },
29
+ "./preset": {
30
+ "types": "./dist/preset.d.ts",
31
+ "import": "./dist/preset.js"
32
+ },
33
+ "./package.json": "./package.json"
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "src",
38
+ "!src/**/*.test.ts",
39
+ "!src/**/*.test.tsx"
40
+ ],
41
+ "dependencies": {
42
+ "@tanstack/history": "^1.162.0"
43
+ },
44
+ "peerDependencies": {
45
+ "@tanstack/react-router": "^1.170.0",
46
+ "@vkontakte/vkui": "^8.0.0",
47
+ "react": "^18.0.0 || ^19.0.0"
48
+ },
49
+ "peerDependenciesMeta": {
50
+ "@vkontakte/vkui": {
51
+ "optional": true
52
+ }
53
+ },
54
+ "devDependencies": {
55
+ "@tanstack/react-router": "^1.170.46",
56
+ "@types/node": "^26.6.5",
57
+ "@types/react": "^19.3.0",
58
+ "@vkontakte/vkui": "^8.4.0",
59
+ "happy-dom": "^21.0.4",
60
+ "publint": "^0.3.25",
61
+ "react": "^19.3.1",
62
+ "tsdown": "^0.23.1",
63
+ "typescript": "^7.0.2",
64
+ "vitest": "^5.0.5"
65
+ },
66
+ "scripts": {
67
+ "build": "tsdown",
68
+ "dev": "tsdown --watch",
69
+ "lint:package": "publint",
70
+ "test": "vitest run",
71
+ "test:watch": "vitest watch"
72
+ }
6
73
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The structural bridge contract: type-compatible with both
3
+ * `@vkontakte/vk-bridge` (whose subscribe handlers receive
4
+ * `{ detail: { type, data } }` CustomEvents) and `@vkma.dev/bridge`
5
+ * (flat `{ type, data }`). The router never hard-depends on either.
6
+ */
7
+
8
+ export interface BridgeEventEnvelope {
9
+ type?: string;
10
+ data?: unknown;
11
+ detail?: { type?: string; data?: unknown };
12
+ }
13
+
14
+ export interface VkBridgeLike {
15
+ send(method: string, params?: Record<string, unknown>): Promise<unknown>;
16
+ subscribe(handler: (event: BridgeEventEnvelope) => void): () => void;
17
+ }
18
+
19
+ export function normalizeBridgeEvent(
20
+ event: BridgeEventEnvelope,
21
+ ): { type: string; data: unknown } | null {
22
+ const type = event.detail?.type ?? event.type;
23
+ const data = event.detail !== undefined ? event.detail.data : event.data;
24
+ return type !== undefined ? { type, data } : null;
25
+ }
@@ -0,0 +1,95 @@
1
+ import { normalizeBridgeEvent, type VkBridgeLike } from "./bridge-like.ts";
2
+ import { parseHashLocation, serializeHashLocation } from "./hash-params.ts";
3
+
4
+ /**
5
+ * The host↔app location sync contract:
6
+ * - every committed history change → `VKWebAppSetLocation`
7
+ * (`replace_state: true`) so the host URL/share reflects the app;
8
+ * - incoming `VKWebAppChangeFragment` / `VKWebAppLocationChanged`
9
+ * (host-initiated jumps) → `history.replace(href)`;
10
+ * - loop protection: an incoming href equal to the current one is ignored.
11
+ *
12
+ * Payload codecs come from the history (they are mode-specific: hash mode
13
+ * sends the hash content, hash-param mode sends `?path=…` preserving
14
+ * foreign params). The initial location is sent once at creation.
15
+ * Serialization happens from the history EVENT's location: subscribers are
16
+ * notified synchronously before the microtask flush writes the URL, so
17
+ * `window.location.hash` is stale by one entry at notify time.
18
+ */
19
+
20
+ const LOCATION_EVENTS = new Set(["VKWebAppChangeFragment", "VKWebAppLocationChanged"]);
21
+
22
+ export interface BridgeSyncOptions {
23
+ history: {
24
+ location: { pathname: string; search: string; hash: string };
25
+ subscribe(
26
+ listener: (event: {
27
+ location: { pathname: string; search: string; hash: string };
28
+ }) => void,
29
+ ): () => void;
30
+ replace(path: string): void;
31
+ };
32
+ bridge: VkBridgeLike;
33
+ /**
34
+ * SetLocation payload for a committed location. Defaults to the hash
35
+ * mode (the hash content); hash-param users pass the codecs from
36
+ * `createVkHistory`.
37
+ */
38
+ serializePayload?: (location: { pathname: string; search: string; hash: string }) => string;
39
+ /** Router href from a SetLocation/ChangeFragment payload. */
40
+ parsePayload?: (payload: string) => string;
41
+ }
42
+
43
+ /** Hash-mode codecs are the defaults. */
44
+ const serializeDefault = (location: { pathname: string; search: string; hash: string }): string =>
45
+ serializeHashLocation(location);
46
+
47
+ const parseDefault = (payload: string): string => parseHashLocation(payload);
48
+
49
+ export function createBridgeSync(options: BridgeSyncOptions): { destroy(): void } {
50
+ const serializePayload = options.serializePayload ?? serializeDefault;
51
+ const parsePayload = options.parsePayload ?? parseDefault;
52
+ let lastSent = "";
53
+
54
+ const sendLocation = (location: { pathname: string; search: string; hash: string }): void => {
55
+ const payload = serializePayload(location);
56
+ if (payload !== lastSent) {
57
+ lastSent = payload;
58
+ void options.bridge.send("VKWebAppSetLocation", {
59
+ location: payload,
60
+ replace_state: true,
61
+ });
62
+ }
63
+ };
64
+
65
+ // the host must learn the location the app started at
66
+ sendLocation(options.history.location);
67
+
68
+ const unsubscribeHistory = options.history.subscribe(event => {
69
+ sendLocation(event.location);
70
+ });
71
+
72
+ const unsubscribeBridge = options.bridge.subscribe(rawEvent => {
73
+ const event = normalizeBridgeEvent(rawEvent);
74
+ if (event === null || !LOCATION_EVENTS.has(event.type)) {
75
+ return;
76
+ }
77
+ const data = event.data as { location?: string } | undefined;
78
+ if (!data?.location) {
79
+ return; // malformed — the host always sends a location; never act on it
80
+ }
81
+ const incoming = parsePayload(data.location);
82
+ const current = `${options.history.location.pathname}${options.history.location.search}`;
83
+ if (incoming === current) {
84
+ return; // echo of our own location — loop protection
85
+ }
86
+ options.history.replace(incoming);
87
+ });
88
+
89
+ return {
90
+ destroy() {
91
+ unsubscribeHistory();
92
+ unsubscribeBridge();
93
+ },
94
+ };
95
+ }