@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,105 @@
1
+ /**
2
+ * Modal choreography state machine — pure, React-free; the `<Modals>` host
3
+ * in `modals.tsx` drives it and completes exits from the surface's
4
+ * `onClosed`. One step per reconcile: every close is an animated exit whose
5
+ * completion triggers the next step. Route ids are hierarchical
6
+ * (`/view:feed/post/$id/comments`), so the nested/sibling relation is a
7
+ * string prefix check.
8
+ *
9
+ * - a SIBLING retarget (comments → share) closes the displayed sheet first
10
+ * and opens the new one only after the exit completes — never two sibling
11
+ * sheets at once (the user-reported bug from the first spike);
12
+ * - a NESTED open (the target is a child route of the displayed modal)
13
+ * keeps the displayed sheet open and stacks the child ON TOP of it — two
14
+ * open sheets by design; back unwinds the stack one level per entry;
15
+ * - back to the parent modal closes the top and reveals the parent.
16
+ *
17
+ * While an exit is in flight:
18
+ * - retargeting BACK to the exiting sheet CANCELS the exit — VKUI reverses
19
+ * the transition mid-flight (`open` flips back to `true`), so there is no
20
+ * close-then-reopen bounce;
21
+ * - any OTHER target is QUEUED: the state's `target` carries the LATEST
22
+ * router request and the exit's completion re-reconciles toward it.
23
+ * Dropping it would leave the host showing a modal the router no longer
24
+ * matches, desynced until the next navigation.
25
+ */
26
+
27
+ interface ChoreographyEntry {
28
+ /** Modal id — also the VKUI `nav`. */
29
+ id: string;
30
+ /** Full route id of the modal route. */
31
+ routeId: string;
32
+ }
33
+
34
+ export interface ChoreographyState<T extends ChoreographyEntry> {
35
+ /** the modal route that should be open on top right now */
36
+ target: T | null;
37
+ /** open entries, bottom → top (nested modals stack) */
38
+ stack: T[];
39
+ /** the entry animating out (mounted with open=false) */
40
+ exiting: T | null;
41
+ /** the exiting id whose `onClosed` completes the transition */
42
+ awaitingExitId: string | null;
43
+ }
44
+
45
+ export const idleChoreography = <T extends ChoreographyEntry>(): ChoreographyState<T> => ({
46
+ target: null,
47
+ stack: [],
48
+ exiting: null,
49
+ awaitingExitId: null,
50
+ });
51
+
52
+ export function reconcile<T extends ChoreographyEntry>(
53
+ previous: ChoreographyState<T>,
54
+ target: T | null,
55
+ ): ChoreographyState<T> {
56
+ if (previous.awaitingExitId !== null) {
57
+ const exiting = previous.exiting;
58
+ if (exiting !== null && target?.id === exiting.id) {
59
+ // retargeting the exiting sheet: CANCEL the exit and restore it
60
+ return {
61
+ ...previous,
62
+ target,
63
+ stack: [...previous.stack, exiting],
64
+ exiting: null,
65
+ awaitingExitId: null,
66
+ };
67
+ }
68
+ // any other target mid-exit: queue it — the exit's completion
69
+ // (handleClosed) re-reconciles toward this latest request
70
+ return { ...previous, target };
71
+ }
72
+ const top = previous.stack[previous.stack.length - 1] ?? null;
73
+ if (top === null) {
74
+ return target === null
75
+ ? { ...previous, target: null }
76
+ : { ...previous, target, stack: [target] };
77
+ }
78
+ if (target !== null && target.id === top.id) {
79
+ return { ...previous, target }; // already on top — settle
80
+ }
81
+ if (target !== null && target.routeId.startsWith(`${top.routeId}/`)) {
82
+ // NESTED open: the target is a child route of the displayed modal —
83
+ // keep it open and stack the child on top
84
+ return { ...previous, target, stack: [...previous.stack, target] };
85
+ }
86
+ if (target !== null && top.routeId.startsWith(`${target.routeId}/`)) {
87
+ // back to an ANCESTOR modal: close the top, the parent stays open
88
+ return {
89
+ ...previous,
90
+ target,
91
+ stack: previous.stack.slice(0, -1),
92
+ exiting: top,
93
+ awaitingExitId: top.id,
94
+ };
95
+ }
96
+ // sibling/unrelated modal or close-everything: close the top first —
97
+ // the target opens when the exit completes
98
+ return {
99
+ ...previous,
100
+ target,
101
+ stack: previous.stack.slice(0, -1),
102
+ exiting: top,
103
+ awaitingExitId: top.id,
104
+ };
105
+ }
@@ -0,0 +1,216 @@
1
+ import { useRouterState } from "@tanstack/react-router";
2
+ import { ModalCard, ModalPage, type ModalCardProps, type ModalPageProps } from "@vkontakte/vkui";
3
+ import {
4
+ type ComponentType,
5
+ createContext,
6
+ type LazyExoticComponent,
7
+ Suspense,
8
+ useContext,
9
+ useEffect,
10
+ useState,
11
+ } from "react";
12
+ import { idleChoreography, reconcile, type ChoreographyState } from "./choreography.ts";
13
+
14
+ /**
15
+ * Modal choreography host. The state machine lives in `choreography.ts`
16
+ * (pure — sibling closes first, nested stacks on top, back reveals the
17
+ * parent, retarget-during-exit cancels or queues). This host binds it to
18
+ * VKUI: exit completion is EVENT-DRIVEN — VKUI fires `onClosed` at the
19
+ * transition end and backs it with an internal timer computed from the
20
+ * element's real `transitionDuration` (adapts to platform/theme/reduced-
21
+ * motion — more accurate than any host-side constant). The host
22
+ * `exitDuration` is only a WATCHDOG for the one case VKUI cannot report:
23
+ * the modal unmounting before the exit finishes (its entry vanished from
24
+ * `entries`).
25
+ */
26
+
27
+ /** A renderable modal entry component — plain or React.lazy. */
28
+ export type ModalEntryComponent = ComponentType | LazyExoticComponent<ComponentType>;
29
+
30
+ export interface ModalEntry {
31
+ /** Full route id of the modal route (e.g. `/item/$id/details`). */
32
+ routeId: string;
33
+ /** Modal id — also the VKUI `nav`. */
34
+ id: string;
35
+ /**
36
+ * The modal content. Its root must be a ModalPageSurface or
37
+ * ModalCardSurface — the host binds nav/open/close choreography to it
38
+ * through context; the component owns every other prop at runtime.
39
+ */
40
+ Component: ModalEntryComponent;
41
+ }
42
+
43
+ export interface ModalsProps {
44
+ entries: ModalEntry[];
45
+ /**
46
+ * Watchdog bound for an exit whose `onClosed` never arrives (the modal
47
+ * unmounted mid-animation), ms. Default 420 — well past any VKUI modal
48
+ * exit animation; a healthy `onClosed` lands long before it fires.
49
+ */
50
+ exitDuration?: number;
51
+ }
52
+
53
+ /** Host-bound surface controls, provided to every mounted modal entry. */
54
+ interface ModalSurfaceApi {
55
+ nav: string;
56
+ open: boolean;
57
+ /** Another modal is stacked on top of this one — suppress the focus trap. */
58
+ stacked: boolean;
59
+ onClose: () => void;
60
+ onClosed: () => void;
61
+ }
62
+
63
+ const ModalSurfaceContext = createContext<ModalSurfaceApi | null>(null);
64
+
65
+ function useModalSurface(): ModalSurfaceApi {
66
+ const api = useContext(ModalSurfaceContext);
67
+ if (api === null) {
68
+ throw new Error(
69
+ "[vkma-router] ModalPageSurface/ModalCardSurface must be the root of a modal entry rendered by <Modals>",
70
+ );
71
+ }
72
+ return api;
73
+ }
74
+
75
+ /**
76
+ * The host-owned keys VKUI actually accepts. Everything else in the api
77
+ * (`stacked` today, future host keys tomorrow) must NOT reach the spread —
78
+ * VKUI forwards unknown props to the DOM and React warns on booleans
79
+ * ("Received `false` for a non-boolean attribute `stacked`").
80
+ */
81
+ export function hostProps(api: ModalSurfaceApi): {
82
+ nav: string;
83
+ open: boolean;
84
+ onClose: () => void;
85
+ onClosed: () => void;
86
+ } {
87
+ const { nav, open, onClose, onClosed } = api;
88
+ return { nav, open, onClose, onClosed };
89
+ }
90
+
91
+ /** ModalPage surface for a modal entry — the host owns nav/open/close choreography. */
92
+ export function ModalPageSurface({
93
+ children,
94
+ ...surface
95
+ }: Omit<ModalPageProps, "nav" | "open" | "onClose" | "onClosed" | "keepMounted">) {
96
+ const api = useModalSurface();
97
+ return (
98
+ <ModalPage
99
+ {...hostProps(api)}
100
+ keepMounted
101
+ disableFocusTrap={api.stacked || undefined}
102
+ {...surface}
103
+ >
104
+ {children}
105
+ </ModalPage>
106
+ );
107
+ }
108
+
109
+ /**
110
+ * ModalCard surface for a modal entry — same contract as
111
+ * ModalPageSurface, with the card's icon/title/description/actions as
112
+ * ordinary runtime props.
113
+ */
114
+ export function ModalCardSurface({
115
+ children,
116
+ ...surface
117
+ }: Omit<ModalCardProps, "nav" | "open" | "onClose" | "onClosed" | "keepMounted">) {
118
+ const api = useModalSurface();
119
+ return (
120
+ <ModalCard
121
+ {...hostProps(api)}
122
+ keepMounted
123
+ disableFocusTrap={api.stacked || undefined}
124
+ {...surface}
125
+ >
126
+ {children}
127
+ </ModalCard>
128
+ );
129
+ }
130
+
131
+ export function Modals({ entries, exitDuration = 420 }: ModalsProps) {
132
+ const routeIds = new Set(entries.map(entry => entry.routeId));
133
+ const matchedModalEntry = useRouterState({
134
+ select: state => {
135
+ for (let i = state.matches.length - 1; i >= 0; i--) {
136
+ const routeId = state.matches[i]?.routeId;
137
+ if (routeId !== undefined && routeIds.has(routeId)) {
138
+ return entries.find(entry => entry.routeId === routeId) ?? null;
139
+ }
140
+ }
141
+ return null;
142
+ },
143
+ });
144
+ const globalModalId = useRouterState({
145
+ select: state => (state.location.search as { modal?: string } | undefined)?.modal ?? null,
146
+ });
147
+ const globalEntry =
148
+ globalModalId !== null ? (entries.find(entry => entry.id === globalModalId) ?? null) : null;
149
+ const target = matchedModalEntry ?? globalEntry;
150
+
151
+ const [state, setState] = useState<ChoreographyState<ModalEntry>>(() =>
152
+ reconcile(idleChoreography<ModalEntry>(), target),
153
+ );
154
+
155
+ // choreograph target changes (see reconcile)
156
+ useEffect(() => {
157
+ setState(previous => reconcile(previous, target));
158
+ }, [target]);
159
+
160
+ const handleClosed = (closedId: string): void => {
161
+ setState(previous => {
162
+ if (previous.awaitingExitId !== closedId) {
163
+ return previous; // stale or unexpected callback
164
+ }
165
+ const settled: ChoreographyState<ModalEntry> = {
166
+ ...previous,
167
+ exiting: null,
168
+ awaitingExitId: null,
169
+ };
170
+ return reconcile(settled, settled.target);
171
+ });
172
+ };
173
+
174
+ // watchdog for the lost-onClosed case (unmount mid-exit); the dep set
175
+ // re-arms it on every new exit and cleans up on completion/unmount
176
+ useEffect(() => {
177
+ if (state.awaitingExitId === null) {
178
+ return;
179
+ }
180
+ const watchdog = setTimeout(
181
+ () => handleClosed(state.awaitingExitId as string),
182
+ exitDuration * 2,
183
+ );
184
+ return () => clearTimeout(watchdog);
185
+ }, [state.awaitingExitId, exitDuration]);
186
+
187
+ // render the open stack (bottom → top) plus the exiting sheet
188
+ const rendered: Array<{ entry: ModalEntry; open: boolean; stacked: boolean }> = [];
189
+ state.stack.forEach((entry, index) => {
190
+ rendered.push({ entry, open: true, stacked: index < state.stack.length - 1 });
191
+ });
192
+ if (state.exiting !== null && !state.stack.some(entry => entry.id === state.exiting?.id)) {
193
+ rendered.push({ entry: state.exiting, open: false, stacked: false });
194
+ }
195
+
196
+ return (
197
+ <>
198
+ {rendered.map(({ entry, open, stacked }) => (
199
+ <ModalSurfaceContext.Provider
200
+ key={entry.id}
201
+ value={{
202
+ nav: entry.id,
203
+ open,
204
+ stacked,
205
+ onClose: () => window.history.back(),
206
+ onClosed: () => handleClosed(entry.id),
207
+ }}
208
+ >
209
+ <Suspense fallback={null}>
210
+ <entry.Component />
211
+ </Suspense>
212
+ </ModalSurfaceContext.Provider>
213
+ ))}
214
+ </>
215
+ );
216
+ }
@@ -0,0 +1,21 @@
1
+ import { Button, Placeholder } from "@vkontakte/vkui";
2
+
3
+ /**
4
+ * Default 404 screen (parity with the old router's DefaultNotFound): wire it
5
+ * as `defaultNotFoundComponent` in `createRouter` — TanStack otherwise
6
+ * renders a bare `<p>Not Found</p>` and warns.
7
+ */
8
+ export function NotFound(): React.ReactNode {
9
+ return (
10
+ <Placeholder
11
+ title="Страница не найдена"
12
+ action={
13
+ <Button size="l" onClick={() => window.history.back()}>
14
+ Назад
15
+ </Button>
16
+ }
17
+ >
18
+ Такой страницы не существует.
19
+ </Placeholder>
20
+ );
21
+ }
@@ -0,0 +1,106 @@
1
+ import { useRouter, useRouterState } from "@tanstack/react-router";
2
+ import { type ComponentType } from "react";
3
+ import { looseRouteOf } from "../core/loose-route.ts";
4
+ import { POPOUT_STATE_KEY, readPopoutState } from "../core/state-keys.ts";
5
+
6
+ /**
7
+ * Popouts are transient overlays, not routes: `history.state` holds
8
+ * `{ popout: id, props }`, so the native/system back closes them, and
9
+ * back/forward restores both the id and the props. Components come from
10
+ * the registry (per-panel and/or global — see the preset builders);
11
+ * `onClose` is injected by the host.
12
+ */
13
+
14
+ export interface PopoutEntry {
15
+ id: string;
16
+ Component: ComponentType<{ onClose: () => void }>;
17
+ /**
18
+ * The owning panel key (`${view}/${panel}`) for a panel-local popout —
19
+ * it resolves only while that panel is the deepest matched one.
20
+ * Undefined = global (resolves from any panel).
21
+ */
22
+ panel?: string;
23
+ }
24
+
25
+ export function usePopoutState(): { id: string; props?: Record<string, unknown> } | undefined {
26
+ return useRouterState({ select: state => readPopoutState(state.location.state) });
27
+ }
28
+
29
+ /** `showPopout(id, props)` — pushes a popout entry; replaces if one is open. */
30
+ export function useShowPopout(): (id: string, props?: Record<string, unknown>) => void {
31
+ const router = useRouter();
32
+ const isOpen = usePopoutState() !== undefined;
33
+ return (id, props) => {
34
+ void router.navigate({
35
+ href: router.state.location.href,
36
+ state: (previous: unknown) =>
37
+ ({
38
+ ...((previous ?? {}) as Record<string, unknown>),
39
+ [POPOUT_STATE_KEY]: { id, props },
40
+ }) as never,
41
+ replace: isOpen,
42
+ });
43
+ };
44
+ }
45
+
46
+ /** `hidePopout()` — unwinds the popout entry; on the first entry, strips in place. */
47
+ export function useHidePopout(): () => void {
48
+ const router = useRouter();
49
+ return () => {
50
+ if (router.history.canGoBack()) {
51
+ router.history.back();
52
+ return;
53
+ }
54
+ const state = {
55
+ ...(router.state.location.state as unknown as Record<string, unknown>),
56
+ };
57
+ delete state[POPOUT_STATE_KEY];
58
+ void router.navigate({
59
+ href: router.state.location.href,
60
+ replace: true,
61
+ state,
62
+ });
63
+ };
64
+ }
65
+
66
+ /** The deepest matched panel's registry key (`${view}/${panel}`), or null. */
67
+ function useActivePanelKey(): string | null {
68
+ const router = useRouter();
69
+ return useRouterState({
70
+ select: (state): string | null => {
71
+ for (let i = state.matches.length - 1; i >= 0; i--) {
72
+ const routeId = state.matches[i]?.routeId;
73
+ if (routeId === undefined) {
74
+ continue;
75
+ }
76
+ const panel = looseRouteOf(router, routeId)?.options.staticData?.panel;
77
+ if (panel !== undefined) {
78
+ return panel;
79
+ }
80
+ }
81
+ return null;
82
+ },
83
+ });
84
+ }
85
+
86
+ export function Popouts({ entries }: { entries: PopoutEntry[] }) {
87
+ const popout = usePopoutState();
88
+ const hide = useHidePopout();
89
+ const activePanelKey = useActivePanelKey();
90
+ if (popout === undefined) {
91
+ return null;
92
+ }
93
+ // resolution local → global: the current panel's own popouts shadow the
94
+ // global registry
95
+ const entry =
96
+ entries.find(
97
+ candidate =>
98
+ candidate.panel !== undefined &&
99
+ candidate.panel === activePanelKey &&
100
+ candidate.id === popout.id,
101
+ ) ?? entries.find(candidate => candidate.panel === undefined && candidate.id === popout.id);
102
+ if (!entry) {
103
+ return null;
104
+ }
105
+ return <entry.Component onClose={hide} />;
106
+ }
@@ -0,0 +1,147 @@
1
+ import type { AnyRoute } from "@tanstack/react-router";
2
+ import { useRouterState } from "@tanstack/react-router";
3
+ import { Panel, Spinner, View } from "@vkontakte/vkui";
4
+ import { type ComponentType } from "react";
5
+ import { usePanelsHistory } from "../react/hooks.ts";
6
+
7
+ export interface PanelStaticData {
8
+ panel: string;
9
+ }
10
+
11
+ export function panelIdOf(route: AnyRoute): string {
12
+ return (route.options.staticData as PanelStaticData | undefined)?.panel ?? "";
13
+ }
14
+
15
+ function deepestPanelMatchId(view: AnyRoute, matchedRouteIds: string[]): string {
16
+ const panelIds = new Set(view.children.map((child: AnyRoute) => child.id));
17
+ for (let i = matchedRouteIds.length - 1; i >= 0; i--) {
18
+ const id = matchedRouteIds[i];
19
+ if (id !== undefined && panelIds.has(id)) {
20
+ return id;
21
+ }
22
+ }
23
+ return view.children[0]?.id ?? "";
24
+ }
25
+
26
+ function useMatchSummaries(): string {
27
+ return useRouterState({
28
+ select: state =>
29
+ // routeId + status: a lazy panel's chunk resolution flips the match
30
+ // status, which must re-render the static children (the resolved
31
+ // component merges into route.options at that point)
32
+ state.matches.map(match => `${match.routeId}#${match.status}`).join("|"),
33
+ });
34
+ }
35
+
36
+ /**
37
+ * The VKUI nav key of a view route: "/view:feed" → "feed". Epic/Root match
38
+ * children by getNavId, so the raw route id would leak the route-tree
39
+ * convention into the UI layer. This is also the key the app author wrote
40
+ * in `defineView("feed")`.
41
+ */
42
+ export function storyKeyOf(routeId: unknown): string {
43
+ const id = String(routeId);
44
+ return id.split(":")[1] ?? id;
45
+ }
46
+
47
+ export interface VkViewProps {
48
+ /** The pathless view layout route created by the preset. */
49
+ view: AnyRoute;
50
+ /** VKUI nav id of the View. Defaults to the story key (the defineView key). */
51
+ nav?: string;
52
+ /** Native swipe-back pop; defaults to `window.history.back()`. */
53
+ onSwipeBack?: () => void;
54
+ }
55
+
56
+ /**
57
+ * Static-children view: every panel route renders as an always-present JSX
58
+ * child of VKUI <View>; the router only feeds `activePanel` (and `history`
59
+ * for the iOS swipe-back). VKUI hides inactive panels itself and infers
60
+ * the animation direction from the children order — a plain <Outlet>
61
+ * breaks both. A lazy panel's `options.component` is undefined until its
62
+ * chunk resolves (TanStack merges loaded options in place), so the panel
63
+ * renders a spinner fallback until then. For the same reason TanStack's
64
+ * Outlet-driven `pendingComponent` swap never happens on its own — while
65
+ * the panel's match is pending (loader/beforeLoad), the route's
66
+ * `pendingComponent` renders instead of the component.
67
+ */
68
+ export function VkView({ view, nav, onSwipeBack }: VkViewProps) {
69
+ useMatchSummaries();
70
+ const matchedRouteIds = useRouterState({
71
+ select: state => state.matches.map(match => match.routeId),
72
+ });
73
+ // structurally-compared array — TanStack keeps the reference stable when
74
+ // the selection is deep-equal. TanStack's Outlet would swap in the
75
+ // route's pendingComponent itself, but static children render the
76
+ // component directly, so the swap is ours to make while the loader runs
77
+ const pendingRouteIds = useRouterState({
78
+ select: state =>
79
+ state.matches
80
+ .filter(match => (match as { status?: string }).status === "pending")
81
+ .map(match => String(match.routeId)),
82
+ });
83
+ // a notFound match (notFound() from beforeLoad/validateSearch) must NOT
84
+ // render its panel component: the component's data guards assume a valid
85
+ // match, and the shell renders the NotFound screen for this navigation —
86
+ // the panel itself stays invisible. Without the gate the component would
87
+ // throw in a transient render and dirty the console on every cold 404
88
+ const notFoundRouteIds = useRouterState({
89
+ select: state =>
90
+ state.matches
91
+ .filter(match => (match as { status?: string }).status === "notFound")
92
+ .map(match => String(match.routeId)),
93
+ });
94
+ const activeRouteId = deepestPanelMatchId(view, matchedRouteIds);
95
+ const activeChild =
96
+ view.children.find((child: AnyRoute) => child.id === activeRouteId) ?? view.children[0];
97
+ const activePanel = activeChild ? panelIdOf(activeChild) : "";
98
+
99
+ const history = usePanelsHistory(activePanel);
100
+
101
+ return (
102
+ <View
103
+ nav={nav ?? storyKeyOf(view.id)}
104
+ activePanel={activePanel}
105
+ history={history}
106
+ onSwipeBack={onSwipeBack ?? (() => window.history.back())}
107
+ >
108
+ {view.children.map((child: AnyRoute) => {
109
+ const id = panelIdOf(child);
110
+ const Component = child.options.component as ComponentType | undefined;
111
+ const Pending = child.options.pendingComponent as ComponentType | undefined;
112
+ const isPending = pendingRouteIds.includes(String(child.id));
113
+ const isNotFound = notFoundRouteIds.includes(String(child.id));
114
+ // pending without a pendingComponent renders the FALLBACK, never
115
+ // the component: a lazy chunk may have already merged its
116
+ // component while the match is still pending (or parked on a
117
+ // notFound boundary up the tree) — the component's data guards
118
+ // assume a resolvable match and would throw in a hidden panel
119
+ return (
120
+ <Panel key={id} nav={id}>
121
+ {isPending ? (
122
+ Pending !== undefined ? (
123
+ <Pending />
124
+ ) : (
125
+ <PanelSpinnerFallback />
126
+ )
127
+ ) : isNotFound ? (
128
+ <PanelSpinnerFallback />
129
+ ) : Component ? (
130
+ <Component />
131
+ ) : (
132
+ <PanelSpinnerFallback />
133
+ )}
134
+ </Panel>
135
+ );
136
+ })}
137
+ </View>
138
+ );
139
+ }
140
+
141
+ function PanelSpinnerFallback() {
142
+ return (
143
+ <div style={{ paddingTop: 64, textAlign: "center" }}>
144
+ <Spinner size="l" />
145
+ </div>
146
+ );
147
+ }
package/src/vkui.ts ADDED
@@ -0,0 +1,17 @@
1
+ export { VkView, panelIdOf, type VkViewProps } from "./vkui/vk-view.tsx";
2
+ export {
3
+ Modals,
4
+ ModalPageSurface,
5
+ ModalCardSurface,
6
+ type ModalEntry,
7
+ type ModalEntryComponent,
8
+ type ModalsProps,
9
+ } from "./vkui/modals.tsx";
10
+ export {
11
+ Popouts,
12
+ usePopoutState,
13
+ useShowPopout,
14
+ useHidePopout,
15
+ type PopoutEntry,
16
+ } from "./vkui/popouts.tsx";
17
+ export { NotFound } from "./vkui/not-found.tsx";
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.