@basmilius/routing 0.0.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.
- package/README.md +21 -0
- package/dist/index.d.mts +98 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +62 -0
- package/src/augmentations.ts +22 -0
- package/src/component/ModalRouterView.ts +34 -0
- package/src/component/RouterLink.ts +129 -0
- package/src/component/RouterView.ts +207 -0
- package/src/composable/useIsView.ts +12 -0
- package/src/composable/useModalRoute.ts +28 -0
- package/src/composable/useNamedRoute.ts +23 -0
- package/src/composable/useNavigate.ts +35 -0
- package/src/composable/useRoute.ts +83 -0
- package/src/composable/useRouteMeta.ts +32 -0
- package/src/composable/useRouteNames.ts +14 -0
- package/src/composable/useRouteParam.ts +16 -0
- package/src/composable/useRouteView.ts +23 -0
- package/src/createRouter.ts +23 -0
- package/src/index.ts +26 -0
- package/src/internal/RoutedView.ts +66 -0
- package/src/internal/modalContext.ts +205 -0
- package/src/internal/modalState.ts +54 -0
- package/src/internal/patchRouter.ts +86 -0
- package/src/internal/resolveModal.ts +20 -0
- package/src/symbol.ts +12 -0
- package/src/types.ts +34 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { computed, type ComputedRef, shallowRef, type ShallowRef, unref, watch } from 'vue';
|
|
2
|
+
import { loadRouteLocation, type RouteLocationNormalized, type Router } from 'vue-router';
|
|
3
|
+
import type { ModalConfig } from '../types';
|
|
4
|
+
import { readModalState, writeModalState } from './modalState';
|
|
5
|
+
import type { OriginalNav } from './patchRouter';
|
|
6
|
+
|
|
7
|
+
// note: True when all matched-record components are already materialised
|
|
8
|
+
// (not still `() => import(...)` functions). Decides whether to assign
|
|
9
|
+
// the background route synchronously or await `loadRouteLocation` first.
|
|
10
|
+
function isFullyLoaded(route: RouteLocationNormalized): boolean {
|
|
11
|
+
for (const record of route.matched) {
|
|
12
|
+
if (!record.components) {
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
for (const name in record.components) {
|
|
17
|
+
const component = record.components[name];
|
|
18
|
+
|
|
19
|
+
if (typeof component === 'function' && !('displayName' in component)) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ModalContext = {
|
|
29
|
+
readonly isModal: ComputedRef<boolean>;
|
|
30
|
+
readonly backgroundRoute: ShallowRef<RouteLocationNormalized | null>;
|
|
31
|
+
readonly depth: ShallowRef<number>;
|
|
32
|
+
readonly initiallyOpen: ShallowRef<boolean>;
|
|
33
|
+
readonly defaultModal: ModalConfig | null;
|
|
34
|
+
// note: Symbol of the `<RouterView modals>` instance that owns the
|
|
35
|
+
// host role. `null` when no host is mounted — modals don't render.
|
|
36
|
+
readonly host: ShallowRef<symbol | null>;
|
|
37
|
+
|
|
38
|
+
promote(): Promise<void>;
|
|
39
|
+
|
|
40
|
+
// note: Reserves the host role. Returns `true` when the role was free
|
|
41
|
+
// and is now ours; `false` when another instance got there first.
|
|
42
|
+
claimHost(id: symbol): boolean;
|
|
43
|
+
|
|
44
|
+
// note: Releases the host role only if `id` matches the current
|
|
45
|
+
// holder, so a late "loser" unmount can't reset the actual host.
|
|
46
|
+
releaseHost(id: symbol): void;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export default function createModalContext(
|
|
50
|
+
router: Router,
|
|
51
|
+
original: OriginalNav,
|
|
52
|
+
defaultModal: ModalConfig | null
|
|
53
|
+
): ModalContext {
|
|
54
|
+
const initial = readModalState();
|
|
55
|
+
|
|
56
|
+
// note: On hard refresh of a modal URL, the background route's lazy
|
|
57
|
+
// chunks aren't loaded yet — `components` are still import functions
|
|
58
|
+
// that Vue would render as promise vnodes inside `<Transition>`. We
|
|
59
|
+
// keep `backgroundRoute` null until `loadRouteLocation` materialises
|
|
60
|
+
// them, then assign once.
|
|
61
|
+
const backgroundRoute = shallowRef<RouteLocationNormalized | null>(null);
|
|
62
|
+
|
|
63
|
+
// note: Number of parent matched records (above the deepest) that
|
|
64
|
+
// render inside the modal wrapper. `0` renders only the deepest;
|
|
65
|
+
// higher values include ancestors as in-modal layout.
|
|
66
|
+
const depth = shallowRef(initial?.depth ?? 0);
|
|
67
|
+
|
|
68
|
+
// note: True between the first render (history.state already shows
|
|
69
|
+
// an open modal) and the moment the background route's async load
|
|
70
|
+
// settles. Wrappers can consume this to skip the opening transition.
|
|
71
|
+
const initiallyOpen = shallowRef(initial !== null);
|
|
72
|
+
|
|
73
|
+
const isModal = computed(() => unref(backgroundRoute) !== null);
|
|
74
|
+
|
|
75
|
+
let loadToken = 0;
|
|
76
|
+
|
|
77
|
+
// note: On refresh, await the background chunks before surfacing the
|
|
78
|
+
// background route. Already-cached chunks assign synchronously so
|
|
79
|
+
// the first render sees the open-modal state.
|
|
80
|
+
if (initial !== null) {
|
|
81
|
+
const token = ++loadToken;
|
|
82
|
+
const snapshot = router.resolve(initial.backgroundPath) as RouteLocationNormalized;
|
|
83
|
+
|
|
84
|
+
if (isFullyLoaded(snapshot)) {
|
|
85
|
+
backgroundRoute.value = snapshot;
|
|
86
|
+
initiallyOpen.value = false;
|
|
87
|
+
} else {
|
|
88
|
+
loadRouteLocation(snapshot)
|
|
89
|
+
.then(() => {
|
|
90
|
+
if (token !== loadToken) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
backgroundRoute.value = router.resolve(initial.backgroundPath) as RouteLocationNormalized;
|
|
95
|
+
initiallyOpen.value = false;
|
|
96
|
+
})
|
|
97
|
+
.catch(() => {
|
|
98
|
+
if (token !== loadToken) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
backgroundRoute.value = null;
|
|
103
|
+
initiallyOpen.value = false;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// note: React to subsequent navigations. `flush: 'pre'` runs before
|
|
109
|
+
// render so RouterView picks up the new background the same tick.
|
|
110
|
+
watch(router.currentRoute, () => {
|
|
111
|
+
if (typeof history === 'undefined') {
|
|
112
|
+
backgroundRoute.value = null;
|
|
113
|
+
depth.value = 0;
|
|
114
|
+
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const state = readModalState();
|
|
119
|
+
|
|
120
|
+
if (state === null) {
|
|
121
|
+
loadToken++;
|
|
122
|
+
backgroundRoute.value = null;
|
|
123
|
+
depth.value = 0;
|
|
124
|
+
initiallyOpen.value = false;
|
|
125
|
+
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
depth.value = state.depth;
|
|
130
|
+
|
|
131
|
+
const token = ++loadToken;
|
|
132
|
+
const backgroundPath = state.backgroundPath;
|
|
133
|
+
const snapshot = router.resolve(backgroundPath) as RouteLocationNormalized;
|
|
134
|
+
|
|
135
|
+
// note: Fast path — chunks already loaded (typical navigation),
|
|
136
|
+
// assign synchronously to avoid a flash of modal-as-fullpage.
|
|
137
|
+
// Slow path — hard refresh, await load to avoid promise vnodes.
|
|
138
|
+
if (isFullyLoaded(snapshot)) {
|
|
139
|
+
backgroundRoute.value = snapshot;
|
|
140
|
+
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
loadRouteLocation(snapshot)
|
|
145
|
+
.then(() => {
|
|
146
|
+
if (token !== loadToken) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
backgroundRoute.value = router.resolve(backgroundPath) as RouteLocationNormalized;
|
|
151
|
+
})
|
|
152
|
+
.catch(() => {
|
|
153
|
+
if (token !== loadToken) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
backgroundRoute.value = null;
|
|
158
|
+
});
|
|
159
|
+
}, {flush: 'pre'});
|
|
160
|
+
|
|
161
|
+
async function promote(): Promise<void> {
|
|
162
|
+
const current = unref(router.currentRoute);
|
|
163
|
+
|
|
164
|
+
// note: Turns the modal's URL into the "real" page. `replace`
|
|
165
|
+
// avoids a duplicate history entry; `force: true` because
|
|
166
|
+
// vue-router otherwise skips a same-URL replace.
|
|
167
|
+
await original.replace({
|
|
168
|
+
path: current.fullPath,
|
|
169
|
+
force: true,
|
|
170
|
+
state: writeModalState(null, null)
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const host = shallowRef<symbol | null>(null);
|
|
175
|
+
|
|
176
|
+
function claimHost(id: symbol): boolean {
|
|
177
|
+
if (host.value !== null) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
host.value = id;
|
|
182
|
+
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function releaseHost(id: symbol): void {
|
|
187
|
+
if (host.value !== id) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
host.value = null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
isModal,
|
|
196
|
+
backgroundRoute,
|
|
197
|
+
depth,
|
|
198
|
+
initiallyOpen,
|
|
199
|
+
defaultModal,
|
|
200
|
+
host,
|
|
201
|
+
promote,
|
|
202
|
+
claimHost,
|
|
203
|
+
releaseHost
|
|
204
|
+
};
|
|
205
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { HistoryState } from 'vue-router';
|
|
2
|
+
|
|
3
|
+
export type ModalState = {
|
|
4
|
+
readonly backgroundPath: string;
|
|
5
|
+
readonly depth: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
// note: Modal fields on `history.state`:
|
|
9
|
+
// { modal: true, modalBackgroundPath: '/users', modalDepth: 0 }
|
|
10
|
+
// - `modal` flags the entry as a modal.
|
|
11
|
+
// - `modalBackgroundPath` is the fullPath rendered behind the wrapper.
|
|
12
|
+
// - `modalDepth` is the parent-record count above the deepest matched
|
|
13
|
+
// record that renders inside the wrapper (0 = deepest only).
|
|
14
|
+
|
|
15
|
+
export function readModalState(): ModalState | null {
|
|
16
|
+
if (typeof history === 'undefined' || !history.state) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const state = history.state as Record<string, unknown>;
|
|
21
|
+
|
|
22
|
+
if (state.modal !== true) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (typeof state.modalBackgroundPath !== 'string' || !state.modalBackgroundPath) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
backgroundPath: state.modalBackgroundPath,
|
|
32
|
+
depth: typeof state.modalDepth === 'number' ? Math.max(0, state.modalDepth) : 0
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function writeModalState(base: HistoryState | null, backgroundPath: string | null, depth: number = 0): HistoryState {
|
|
37
|
+
const next: HistoryState = {...(base ?? {})};
|
|
38
|
+
|
|
39
|
+
if (backgroundPath === null) {
|
|
40
|
+
// note: `null` (not `delete`) so vue-router's state-merging
|
|
41
|
+
// unambiguously wipes the previous value.
|
|
42
|
+
next.modal = null;
|
|
43
|
+
next.modalBackgroundPath = null;
|
|
44
|
+
next.modalDepth = null;
|
|
45
|
+
|
|
46
|
+
return next;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
next.modal = true;
|
|
50
|
+
next.modalBackgroundPath = backgroundPath;
|
|
51
|
+
next.modalDepth = Math.max(0, depth);
|
|
52
|
+
|
|
53
|
+
return next;
|
|
54
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { unref } from 'vue';
|
|
2
|
+
import type { HistoryState, NavigationFailure, RouteLocationOptions, RouteLocationRaw, Router } from 'vue-router';
|
|
3
|
+
import { readModalState, writeModalState } from './modalState';
|
|
4
|
+
|
|
5
|
+
type Navigate = (to: RouteLocationRaw) => Promise<NavigationFailure | void | undefined>;
|
|
6
|
+
|
|
7
|
+
export type OriginalNav = {
|
|
8
|
+
readonly push: Navigate;
|
|
9
|
+
readonly replace: Navigate;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function withState(to: RouteLocationRaw, state: HistoryState): RouteLocationRaw {
|
|
13
|
+
if (typeof to === 'string') {
|
|
14
|
+
return {path: to, state};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return {...to, state};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function injectState(to: RouteLocationRaw, backgroundPath: string | null, depth: number = 0): RouteLocationRaw {
|
|
21
|
+
const base = typeof to === 'string' ? null : (to.state ?? null);
|
|
22
|
+
|
|
23
|
+
return withState(to, writeModalState(base, backgroundPath, depth));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readModalFlag(flag: boolean | number | undefined): {open: boolean; depth: number; explicitClose: boolean} {
|
|
27
|
+
if (flag === true) {
|
|
28
|
+
return {open: true, depth: 0, explicitClose: false};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (flag === false) {
|
|
32
|
+
return {open: false, depth: 0, explicitClose: true};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (typeof flag === 'number') {
|
|
36
|
+
return {open: true, depth: Math.max(0, flag), explicitClose: false};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {open: false, depth: 0, explicitClose: false};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default function patchRouter(router: Router): OriginalNav {
|
|
43
|
+
const origPush: Navigate = router.push.bind(router);
|
|
44
|
+
const origReplace: Navigate = router.replace.bind(router);
|
|
45
|
+
|
|
46
|
+
function transform(to: RouteLocationRaw): RouteLocationRaw {
|
|
47
|
+
const wantsModal = typeof to === 'string' ? undefined : (to as RouteLocationOptions).modal;
|
|
48
|
+
const flag = readModalFlag(wantsModal);
|
|
49
|
+
const current = readModalState();
|
|
50
|
+
|
|
51
|
+
// note: Explicit `modal: true` / `<number>` opens on top of the
|
|
52
|
+
// current route. Already-open modals reuse the existing background.
|
|
53
|
+
if (flag.open) {
|
|
54
|
+
const backgroundPath = current?.backgroundPath ?? unref(router.currentRoute).fullPath;
|
|
55
|
+
|
|
56
|
+
return injectState(to, backgroundPath, flag.depth);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// note: Explicit `modal: false` exits the modal entirely.
|
|
60
|
+
if (flag.explicitClose) {
|
|
61
|
+
return injectState(to, null);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// note: Implicit navigation from within a modal. Keep the modal open
|
|
65
|
+
// when the target shares the root-matched record (sibling within
|
|
66
|
+
// the same modal tree), otherwise exit. Depth carries across.
|
|
67
|
+
if (current !== null) {
|
|
68
|
+
const resolved = router.resolve(to);
|
|
69
|
+
const currentRoot = unref(router.currentRoute).matched[0]?.path;
|
|
70
|
+
const nextRoot = resolved.matched[0]?.path;
|
|
71
|
+
const sameRoot = !!currentRoot && currentRoot === nextRoot;
|
|
72
|
+
|
|
73
|
+
return sameRoot ? injectState(to, current.backgroundPath, current.depth) : injectState(to, null);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return to;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
router.push = async (to) => await origPush(transform(to));
|
|
80
|
+
router.replace = async (to) => await origReplace(transform(to));
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
push: origPush,
|
|
84
|
+
replace: origReplace
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { RouteLocationNormalized } from 'vue-router';
|
|
2
|
+
import type { ModalConfig } from '../types';
|
|
3
|
+
|
|
4
|
+
// note: Walks matched records deepest-first so children override their
|
|
5
|
+
// parent's `meta.modal`. Falls back to `defaultModal` from
|
|
6
|
+
// `createRouter()` when no record declares one.
|
|
7
|
+
export default function resolveModal(
|
|
8
|
+
route: RouteLocationNormalized,
|
|
9
|
+
fallback: ModalConfig | null
|
|
10
|
+
): ModalConfig | null {
|
|
11
|
+
for (let i = route.matched.length - 1; i >= 0; --i) {
|
|
12
|
+
const modal = route.matched[i]?.meta?.modal;
|
|
13
|
+
|
|
14
|
+
if (modal) {
|
|
15
|
+
return modal;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return fallback;
|
|
20
|
+
}
|
package/src/symbol.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ComputedRef, InjectionKey, Ref, ShallowRef } from 'vue';
|
|
2
|
+
import type { RouteLocationNormalized } from 'vue-router';
|
|
3
|
+
import type { ModalContext } from './internal/modalContext';
|
|
4
|
+
|
|
5
|
+
export const modalContextKey: InjectionKey<ModalContext> = Symbol('basmilius:routing:modal-context');
|
|
6
|
+
export const routeOverrideKey: InjectionKey<ComputedRef<RouteLocationNormalized>> = Symbol('basmilius:routing:route-override');
|
|
7
|
+
export const isInModalKey: InjectionKey<Ref<boolean>> = Symbol('basmilius:routing:in-modal');
|
|
8
|
+
|
|
9
|
+
// note: Gates the modal's inner `<VueRouterView>` for one tick on a
|
|
10
|
+
// user-triggered open so the wrapper's `<Transition>` plays the enter
|
|
11
|
+
// animation. See `RouterView.ts` for the full rationale.
|
|
12
|
+
export const innerReadyKey: InjectionKey<ShallowRef<boolean>> = Symbol('basmilius:routing:inner-ready');
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Component } from 'vue';
|
|
2
|
+
import type { RouteLocationNormalized, RouterOptions as VueRouterOptions } from 'vue-router';
|
|
3
|
+
|
|
4
|
+
export type ModalConfig = {
|
|
5
|
+
readonly component: Component;
|
|
6
|
+
readonly props?: Record<string, unknown>;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
// note: Props our `RouterView` passes to every modal wrapper component
|
|
10
|
+
// at runtime. Declare on your wrapper via `defineProps<ModalWrapperProps>()`.
|
|
11
|
+
// - `modalActive`: open/close flag for script-level use (disable inputs
|
|
12
|
+
// while closing, etc.).
|
|
13
|
+
// - `modalReady`: v-if gate for the inner `<ModalRouterView>`. False at
|
|
14
|
+
// mount and during the close phase so the wrapper's `<Transition>` has
|
|
15
|
+
// an empty slot to animate from / to.
|
|
16
|
+
export type ModalWrapperProps = {
|
|
17
|
+
readonly modalRoute: RouteLocationNormalized;
|
|
18
|
+
readonly modalActive: boolean;
|
|
19
|
+
readonly modalReady: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// note: Shadows vue-router's `RouterOptions` via `index.ts`. Adds
|
|
23
|
+
// `defaultModal` as the fallback wrapper for modal routes without
|
|
24
|
+
// their own `meta.modal`.
|
|
25
|
+
export type RouterOptions = VueRouterOptions & {
|
|
26
|
+
readonly defaultModal?: ModalConfig;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// note: Props on our `<RouterView>`. `modals` opts the instance in as
|
|
30
|
+
// the host for the modal layer. Exactly one `<RouterView>` should set
|
|
31
|
+
// it; multiple → first mount wins, others warn and render vanilla.
|
|
32
|
+
export type RouterViewProps = {
|
|
33
|
+
readonly modals?: boolean;
|
|
34
|
+
};
|