@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.
- package/LICENSE +21 -0
- package/dist/core/bridge-like.d.ts +25 -0
- package/dist/core/bridge-like.d.ts.map +1 -0
- package/dist/core/bridge-like.js +13 -0
- package/dist/core/bridge-like.js.map +1 -0
- package/dist/core/bridge-sync.d.ts +37 -0
- package/dist/core/bridge-sync.d.ts.map +1 -0
- package/dist/core/bridge-sync.js +58 -0
- package/dist/core/bridge-sync.js.map +1 -0
- package/dist/core/hash-params.d.ts +40 -0
- package/dist/core/hash-params.d.ts.map +1 -0
- package/dist/core/hash-params.js +60 -0
- package/dist/core/hash-params.js.map +1 -0
- package/dist/core/loose-route.js +9 -0
- package/dist/core/loose-route.js.map +1 -0
- package/dist/core/panels-stack.d.ts +28 -0
- package/dist/core/panels-stack.d.ts.map +1 -0
- package/dist/core/panels-stack.js +60 -0
- package/dist/core/panels-stack.js.map +1 -0
- package/dist/core/state-keys.d.ts +10 -0
- package/dist/core/state-keys.d.ts.map +1 -0
- package/dist/core/state-keys.js +21 -0
- package/dist/core/state-keys.js.map +1 -0
- package/dist/core/swipe-back.d.ts +25 -0
- package/dist/core/swipe-back.d.ts.map +1 -0
- package/dist/core/swipe-back.js +53 -0
- package/dist/core/swipe-back.js.map +1 -0
- package/dist/core/vk-history.d.ts +41 -0
- package/dist/core/vk-history.d.ts.map +1 -0
- package/dist/core/vk-history.js +115 -0
- package/dist/core/vk-history.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/preset/app.d.ts +35 -0
- package/dist/preset/app.d.ts.map +1 -0
- package/dist/preset/app.js +134 -0
- package/dist/preset/app.js.map +1 -0
- package/dist/preset/builders.d.ts +209 -0
- package/dist/preset/builders.d.ts.map +1 -0
- package/dist/preset/builders.js +209 -0
- package/dist/preset/builders.js.map +1 -0
- package/dist/preset/define-panel.d.ts +77 -0
- package/dist/preset/define-panel.d.ts.map +1 -0
- package/dist/preset/define-panel.js +42 -0
- package/dist/preset/define-panel.js.map +1 -0
- package/dist/preset/define-view.d.ts +42 -0
- package/dist/preset/define-view.d.ts.map +1 -0
- package/dist/preset/define-view.js +43 -0
- package/dist/preset/define-view.js.map +1 -0
- package/dist/preset/inflate.d.ts +17 -0
- package/dist/preset/inflate.d.ts.map +1 -0
- package/dist/preset/inflate.js +40 -0
- package/dist/preset/inflate.js.map +1 -0
- package/dist/preset/navigator.d.ts +75 -0
- package/dist/preset/navigator.d.ts.map +1 -0
- package/dist/preset/navigator.js +116 -0
- package/dist/preset/navigator.js.map +1 -0
- package/dist/preset.d.ts +17 -0
- package/dist/preset.js +16 -0
- package/dist/react/hooks.d.ts +69 -0
- package/dist/react/hooks.d.ts.map +1 -0
- package/dist/react/hooks.js +101 -0
- package/dist/react/hooks.js.map +1 -0
- package/dist/react.d.ts +2 -0
- package/dist/react.js +1 -0
- package/dist/vkui/choreography.js +59 -0
- package/dist/vkui/choreography.js.map +1 -0
- package/dist/vkui/modals.d.ts +49 -0
- package/dist/vkui/modals.d.ts.map +1 -0
- package/dist/vkui/modals.js +116 -0
- package/dist/vkui/modals.js.map +1 -0
- package/dist/vkui/not-found.d.ts +9 -0
- package/dist/vkui/not-found.d.ts.map +1 -0
- package/dist/vkui/not-found.js +23 -0
- package/dist/vkui/not-found.js.map +1 -0
- package/dist/vkui/popouts.d.ts +34 -0
- package/dist/vkui/popouts.d.ts.map +1 -0
- package/dist/vkui/popouts.js +69 -0
- package/dist/vkui/popouts.js.map +1 -0
- package/dist/vkui/vk-view.d.ts +26 -0
- package/dist/vkui/vk-view.d.ts.map +1 -0
- package/dist/vkui/vk-view.js +81 -0
- package/dist/vkui/vk-view.js.map +1 -0
- package/dist/vkui.d.ts +5 -0
- package/dist/vkui.js +5 -0
- package/package.json +70 -3
- package/src/core/bridge-like.ts +25 -0
- package/src/core/bridge-sync.ts +95 -0
- package/src/core/hash-params.ts +73 -0
- package/src/core/loose-route.ts +17 -0
- package/src/core/panels-stack.ts +85 -0
- package/src/core/state-keys.ts +39 -0
- package/src/core/swipe-back.ts +80 -0
- package/src/core/vk-history.ts +146 -0
- package/src/index.ts +30 -0
- package/src/preset/app.tsx +192 -0
- package/src/preset/builders.tsx +566 -0
- package/src/preset/define-panel.ts +109 -0
- package/src/preset/define-view.ts +73 -0
- package/src/preset/inflate.ts +57 -0
- package/src/preset/navigator.ts +250 -0
- package/src/preset.ts +51 -0
- package/src/react/hooks.ts +168 -0
- package/src/react.ts +11 -0
- package/src/vkui/choreography.ts +105 -0
- package/src/vkui/modals.tsx +216 -0
- package/src/vkui/not-found.tsx +21 -0
- package/src/vkui/popouts.tsx +106 -0
- package/src/vkui/vk-view.tsx +147 -0
- package/src/vkui.ts +17 -0
- 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