@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.
@@ -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
+ };