@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,207 @@
1
+ import { type Component, defineComponent, Fragment, h, inject, nextTick, onBeforeUnmount, provide, type Ref, shallowRef, unref, type VNodeChild, watch } from 'vue';
2
+ import { RouterView as VueRouterView, useRoute, useRouter, viewDepthKey } from 'vue-router';
3
+ import { BackgroundProvider, ModalProvider } from '../internal/RoutedView';
4
+ import resolveModal from '../internal/resolveModal';
5
+ import { innerReadyKey, modalContextKey, routeOverrideKey } from '../symbol';
6
+ import type { ModalConfig } from '../types';
7
+
8
+ const RouterView: Component = defineComponent({
9
+ name: 'RouterView',
10
+ inheritAttrs: false,
11
+ props: {
12
+ // note: Opts this instance in as the host that renders the modal
13
+ // layer on top of the background route. Exactly one `<RouterView>`
14
+ // in the tree should set this; if multiple do, the first to mount
15
+ // wins (others render vanilla and emit a console warning).
16
+ modals: {
17
+ type: Boolean,
18
+ default: false
19
+ }
20
+ },
21
+ setup(props, {attrs, slots}) {
22
+ const ctx = inject(modalContextKey, null);
23
+ const override = inject(routeOverrideKey, null);
24
+ const route = useRoute();
25
+ const router = useRouter();
26
+
27
+ // note: Vue-router injects `viewDepthKey` from the parent
28
+ // `VueRouterView` (or 0 at the top). It can be a number or a
29
+ // `Ref<number>`; `unref` collapses both. Passed to
30
+ // `BackgroundProvider` so the background tree resumes at the
31
+ // correct matched index when this RouterView is nested.
32
+ const injectedViewDepth = inject(viewDepthKey, 0);
33
+
34
+ // note: Identity used to claim/release the modal host role on the
35
+ // shared `modalContext`. The matching unmount release targets
36
+ // exactly our claim — never a successor's.
37
+ const hostId = Symbol('basmilius:routing:router-view-host');
38
+
39
+ let isModalHost = false;
40
+
41
+ if (props.modals && override === null && ctx) {
42
+ isModalHost = ctx.claimHost(hostId);
43
+
44
+ if (!isModalHost) {
45
+ // eslint-disable-next-line no-console
46
+ console.warn(
47
+ '[routing] Multiple <RouterView modals> instances detected. ' +
48
+ 'The first mounted instance owns the modal host role; this ' +
49
+ 'instance will render as a vanilla RouterView.'
50
+ );
51
+ } else {
52
+ onBeforeUnmount(() => ctx.releaseHost(hostId));
53
+ }
54
+ }
55
+
56
+ // note: Wrapper component (e.g. FluxOverlay) stays mounted across
57
+ // modal close for its leave animation, and is reused when
58
+ // consecutive modals share the same wrapper.
59
+ const lastModal = shallowRef<ModalConfig | null>(null);
60
+
61
+ // note: Held as a ref because Vue's runtime `h()` can miss
62
+ // primitive prop updates on `ModalProvider` when route changes
63
+ // patch in the same tick. Ref identity stays stable.
64
+ const modalDepthRef: Ref<number> = shallowRef(0);
65
+
66
+ // note: Gates whether the modal's inner `<RouterView>` is attached.
67
+ // On a user-triggered open we hold it back one tick so the
68
+ // wrapper's `<Transition>` observes "no child -> child" and plays
69
+ // its enter animation. On hard refresh of a modal URL we skip the
70
+ // gate so the page arrives already-open without animating.
71
+ const innerReady = shallowRef(false);
72
+
73
+ // note: Snapshot at setup — `modalContext` flips `initiallyOpen`
74
+ // back to `false` once the background route resolves, which is
75
+ // too early to read from the watcher below.
76
+ const wasInitiallyOpen = ctx?.initiallyOpen.value ?? false;
77
+
78
+ let hasSeenActiveModal = false;
79
+
80
+ // note: Exposed so `ModalRouterView` inside consumer wrapper
81
+ // templates honours the same one-tick delay as the fallback slot.
82
+ provide(innerReadyKey, innerReady);
83
+
84
+ if (ctx && isModalHost) {
85
+ watch(() => ctx.backgroundRoute.value !== null, async (isOpen) => {
86
+ if (!isOpen) {
87
+ innerReady.value = false;
88
+
89
+ return;
90
+ }
91
+
92
+ const isFirstActivation = !hasSeenActiveModal;
93
+ hasSeenActiveModal = true;
94
+
95
+ // note: Pageload of a modal URL — wrapper and inner mount
96
+ // together without a transition.
97
+ if (isFirstActivation && wasInitiallyOpen) {
98
+ innerReady.value = true;
99
+
100
+ return;
101
+ }
102
+
103
+ // note: User-triggered open — wrapper renders first with
104
+ // an empty slot, inner attaches next tick to animate in.
105
+ innerReady.value = false;
106
+ await nextTick();
107
+ innerReady.value = true;
108
+ }, {immediate: true, flush: 'pre'});
109
+ }
110
+
111
+ return (): VNodeChild => {
112
+ // note: Already inside a BackgroundProvider / ModalProvider
113
+ // subtree — render vanilla so we don't recurse into another
114
+ // background/modal split.
115
+ if (override !== null) {
116
+ return h(VueRouterView, attrs, slots);
117
+ }
118
+
119
+ if (!ctx) {
120
+ return h(VueRouterView, attrs, slots);
121
+ }
122
+
123
+ // note: Only the instance that claimed the host role renders
124
+ // modals. All others fall through to vanilla.
125
+ if (!isModalHost) {
126
+ return h(VueRouterView, attrs, slots);
127
+ }
128
+
129
+ const backgroundRoute = ctx.backgroundRoute.value;
130
+ const modalActive = ctx.isModal.value && backgroundRoute !== null;
131
+
132
+ // note: Resolve inline (not via watcher) — at setup the route
133
+ // is `START_LOCATION` with empty `matched`, so a watcher with
134
+ // `immediate: true` would miss the per-route `meta.modal`.
135
+ if (modalActive) {
136
+ const resolved = resolveModal(route, ctx.defaultModal);
137
+
138
+ if (resolved !== null && resolved !== lastModal.value) {
139
+ lastModal.value = resolved;
140
+ }
141
+ }
142
+
143
+ const wrapperConfig = lastModal.value;
144
+
145
+ // note: Never been a modal here -> plain RouterView.
146
+ if (!modalActive && !wrapperConfig) {
147
+ return h(VueRouterView, attrs, slots);
148
+ }
149
+
150
+ // note: Background renders the stored route while open; the
151
+ // current route while closed (wrapper lingering for leave
152
+ // animation). Keeps layout-level `useRoute()` stable.
153
+ const bgRoute = modalActive ? backgroundRoute : route;
154
+
155
+ // note: `ctx.depth` is the user-supplied parent count. Translate
156
+ // to an absolute `matched[]` index:
157
+ // depth 0 -> matched[length - 1] (deepest only)
158
+ // depth N -> matched[length - 1 - N]
159
+ const parentCount = ctx.depth.value;
160
+ const viewDepth = Math.max(0, route.matched.length - 1 - parentCount);
161
+
162
+ modalDepthRef.value = viewDepth;
163
+
164
+ // note: ModalProvider wraps the wrapper component so the
165
+ // wrapper's internal Teleport still inherits the provider
166
+ // context. Inner is `undefined` while closed or gated, so
167
+ // the wrapper's `<Transition>` observes an empty slot.
168
+ const modalInner = modalActive && innerReady.value
169
+ ? h(VueRouterView)
170
+ : undefined;
171
+
172
+ const wrappedModalInner = wrapperConfig
173
+ ? h(wrapperConfig.component, {
174
+ ...(wrapperConfig.props ?? {}),
175
+ // note: Runtime props after the spread so consumers
176
+ // can't shadow them via `meta.modal.props`. Use
177
+ // `modalReady` (not `modalActive`) to v-if the
178
+ // inner `<ModalRouterView>` — `modalActive` is true
179
+ // at mount and would skip the enter animation.
180
+ modalRoute: route,
181
+ modalActive,
182
+ modalReady: modalActive && innerReady.value,
183
+ onClose: (): void => {
184
+ router.back();
185
+ }
186
+ }, {
187
+ default: (): VNodeChild => modalInner
188
+ })
189
+ : modalInner;
190
+
191
+ const modalLayer = h(ModalProvider, {route, depthRef: modalDepthRef}, {
192
+ default: (): VNodeChild => wrappedModalInner
193
+ });
194
+
195
+ const hostViewDepth = unref(injectedViewDepth);
196
+
197
+ return h(Fragment, [
198
+ h(BackgroundProvider, {route: bgRoute, viewDepth: hostViewDepth}, {
199
+ default: (): VNodeChild => h(VueRouterView, attrs, slots)
200
+ }),
201
+ modalLayer
202
+ ]);
203
+ };
204
+ }
205
+ });
206
+
207
+ export default RouterView;
@@ -0,0 +1,12 @@
1
+ import { computed, type ComputedRef, unref } from 'vue';
2
+ import useRouteNames from './useRouteNames';
3
+
4
+ export default function (name: string, loose: boolean = false): ComputedRef<boolean> {
5
+ const names = useRouteNames();
6
+
7
+ if (loose) {
8
+ return computed(() => unref(names).some(n => n.startsWith(name)));
9
+ }
10
+
11
+ return computed(() => unref(names).some(n => n === name));
12
+ }
@@ -0,0 +1,28 @@
1
+ import { computed, type ComputedRef, inject, unref } from 'vue';
2
+ import { type RouteLocationNormalized, useRoute as useVueRoute } from 'vue-router';
3
+ import { isInModalKey, modalContextKey, routeOverrideKey } from '../symbol';
4
+
5
+ // note: Surfaces the route driving the currently active modal, or `null`
6
+ // when no modal is active. Inside a ModalProvider subtree the override
7
+ // IS the modal route; outside, the router's live currentRoute is the
8
+ // modal route (background is stashed on the context).
9
+ // `BackgroundProvider` also sets `routeOverrideKey`, so `isInModalKey`
10
+ // is the discriminator between background and modal subtrees.
11
+ export default function (): ComputedRef<RouteLocationNormalized | null> {
12
+ const override = inject(routeOverrideKey, null);
13
+ const isInModal = inject(isInModalKey, null);
14
+ const ctx = inject(modalContextKey, null);
15
+ const rawRoute = useVueRoute();
16
+
17
+ return computed(() => {
18
+ if (isInModal !== null && unref(isInModal) && override !== null) {
19
+ return unref(override);
20
+ }
21
+
22
+ if (ctx !== null && unref(ctx.isModal)) {
23
+ return rawRoute;
24
+ }
25
+
26
+ return null;
27
+ });
28
+ }
@@ -0,0 +1,23 @@
1
+ import { computed, type ComputedRef, provide, type Ref, unref } from 'vue';
2
+ import { viewDepthKey } from 'vue-router';
3
+ import useRoute, { type UseRoute } from './useRoute';
4
+
5
+ export default function (nameRef: Ref<string> | string): UseNamedRoute {
6
+ const route = useRoute();
7
+
8
+ const depth = computed(() => unref(route).matched.findIndex(m => !!m.components && unref(nameRef) in m.components));
9
+ const matched = computed(() => unref(route).matched[unref(depth)]);
10
+ const viewKey = computed(() => unref(matched)?.path);
11
+
12
+ provide(viewDepthKey, depth);
13
+
14
+ return {
15
+ route,
16
+ viewKey
17
+ };
18
+ }
19
+
20
+ type UseNamedRoute = {
21
+ readonly route: UseRoute;
22
+ readonly viewKey: ComputedRef<string | undefined>;
23
+ };
@@ -0,0 +1,35 @@
1
+ import { type NavigationFailure, type RouteLocationRaw, useRouter } from 'vue-router';
2
+
3
+ type Result = NavigationFailure | void | undefined;
4
+ type To = Omit<RouteLocationRaw, 'replace'>;
5
+ type Navigate = (to: To, replace?: boolean) => Promise<Result>;
6
+ type Wrap = (fn: Navigate) => Navigate;
7
+
8
+ export default function (...wrap: Wrap[]): UseNavigate {
9
+ const router = useRouter();
10
+
11
+ let navigate: Navigate = async (to: To, replace: boolean = false) => {
12
+ if (replace) {
13
+ return await router.replace(to);
14
+ }
15
+
16
+ return await router.push(to);
17
+ };
18
+
19
+ for (const wrapper of wrap) {
20
+ navigate = wrapper(navigate);
21
+ }
22
+
23
+ return {
24
+ navigate,
25
+
26
+ push: (to: To) => navigate(to),
27
+ replace: (to: To) => navigate(to, true)
28
+ };
29
+ }
30
+
31
+ type UseNavigate = {
32
+ navigate(to: To, replace?: boolean): Promise<Result>;
33
+ push(to: To): Promise<Result>;
34
+ replace(to: To): Promise<Result>;
35
+ };
@@ -0,0 +1,83 @@
1
+ import { computed, inject, shallowRef, unref } from 'vue';
2
+ import { type RouteLocationNormalizedLoaded, useRoute as useVueRoute } from 'vue-router';
3
+ import { isInModalKey, modalContextKey, routeOverrideKey } from '../symbol';
4
+
5
+ export type UseRoute = RouteLocationNormalizedLoaded & {
6
+ readonly isModal: boolean;
7
+
8
+ promote(): Promise<void>;
9
+ };
10
+
11
+ const FALSE_REF = shallowRef(false);
12
+ const NOOP = async (): Promise<void> => undefined;
13
+
14
+ export default function (): UseRoute {
15
+ const override = inject(routeOverrideKey, null);
16
+ const ctx = inject(modalContextKey, null);
17
+ const isModalRef = inject(isInModalKey, FALSE_REF);
18
+ const raw = useVueRoute();
19
+
20
+ const routeRef = computed<RouteLocationNormalizedLoaded>(() => {
21
+ // note: Inside a Background/ModalProvider subtree — use the
22
+ // explicit override so background-tree components see the
23
+ // background route and modal-wrapper components see the modal
24
+ // route, independent of the router's live currentRoute.
25
+ if (override !== null) {
26
+ return unref(override) as RouteLocationNormalizedLoaded;
27
+ }
28
+
29
+ // note: Outside any provider (top-level layouts) — surface the
30
+ // background route while a modal is open so layout-level
31
+ // `:key`s stay stable across opens/closes.
32
+ if (ctx !== null && unref(ctx.isModal)) {
33
+ const bg = unref(ctx.backgroundRoute);
34
+
35
+ if (bg !== null) {
36
+ return bg as RouteLocationNormalizedLoaded;
37
+ }
38
+ }
39
+
40
+ return raw;
41
+ });
42
+
43
+ const promote = ctx !== null ? ctx.promote : NOOP;
44
+
45
+ // note: Proxy (not a Ref) so the object can be used directly in
46
+ // templates without auto-unwrapping stripping our extras. All
47
+ // route properties flow through `routeRef.value` to keep reactivity
48
+ // tracking intact in templates, computeds, and watchers.
49
+ return new Proxy({} as UseRoute, {
50
+ get(_target, key) {
51
+ if (key === 'isModal') {
52
+ return isModalRef.value;
53
+ }
54
+
55
+ if (key === 'promote') {
56
+ return promote;
57
+ }
58
+
59
+ return (routeRef.value as unknown as Record<PropertyKey, unknown>)[key];
60
+ },
61
+ has(_target, key) {
62
+ if (key === 'isModal' || key === 'promote') {
63
+ return true;
64
+ }
65
+
66
+ return key in routeRef.value;
67
+ },
68
+ ownKeys(_target) {
69
+ return [...Reflect.ownKeys(routeRef.value), 'isModal', 'promote'];
70
+ },
71
+ getOwnPropertyDescriptor(_target, key) {
72
+ if (key === 'isModal') {
73
+ return {configurable: true, enumerable: true, value: isModalRef.value};
74
+ }
75
+
76
+ if (key === 'promote') {
77
+ return {configurable: true, enumerable: true, value: promote};
78
+ }
79
+
80
+ return Object.getOwnPropertyDescriptor(routeRef.value, key);
81
+ }
82
+ });
83
+ }
@@ -0,0 +1,32 @@
1
+ import { merge } from 'lodash-es';
2
+ import { computed, type ComputedRef, unref } from 'vue';
3
+ import type { RouteMeta } from 'vue-router';
4
+ import useRoute from './useRoute';
5
+
6
+ export default function (): ComputedRef<RouteMeta> {
7
+ const route = useRoute();
8
+
9
+ return computed(() => {
10
+ const matched = unref(route).matched;
11
+ let meta: RouteMeta = {};
12
+
13
+ for (let i = matched.length - 1; i >= 0; --i) {
14
+ const record = matched[i];
15
+
16
+ if (!record || typeof record.meta !== 'object') {
17
+ continue;
18
+ }
19
+
20
+ let matchMeta = {...record.meta};
21
+
22
+ if ('navigation' in meta) {
23
+ const {navigation: _navigation, ...matchMetaWithoutNavigation} = matchMeta;
24
+ matchMeta = matchMetaWithoutNavigation;
25
+ }
26
+
27
+ meta = merge(meta, matchMeta);
28
+ }
29
+
30
+ return meta;
31
+ });
32
+ }
@@ -0,0 +1,14 @@
1
+ import { computed, type ComputedRef, unref } from 'vue';
2
+ import useRoute from './useRoute';
3
+
4
+ export default function (): ComputedRef<string[]> {
5
+ const route = useRoute();
6
+
7
+ return computed(() => {
8
+ const names: string[] = [];
9
+
10
+ unref(route).matched.forEach(m => m.name && names.push(m.name as string));
11
+
12
+ return names;
13
+ });
14
+ }
@@ -0,0 +1,16 @@
1
+ import { type Ref, ref, unref, watch } from 'vue';
2
+ import useRoute from './useRoute';
3
+
4
+ export default function (name: string, defaultValue: string | null = null): Ref<string | null> {
5
+ const route = useRoute();
6
+ const param = ref<string | null>(null);
7
+
8
+ // note: `defaultValue` applies on every read of a missing/empty
9
+ // param, not just at mount. The `immediate: true` flush handles
10
+ // the initial value so we don't duplicate the fallback logic.
11
+ watch(() => unref(route).params[name] as string | undefined, (value) => {
12
+ param.value = value || defaultValue;
13
+ }, {immediate: true});
14
+
15
+ return param;
16
+ }
@@ -0,0 +1,23 @@
1
+ import { computed, type ComputedRef, type Ref, unref } from 'vue';
2
+ import type { RouteComponent } from 'vue-router';
3
+ import useRoute from './useRoute';
4
+
5
+ export default function (nameRef: Ref<string> | string): ComputedRef<RouteComponent | null> {
6
+ const route = useRoute();
7
+
8
+ return computed(() => {
9
+ const name = unref(nameRef);
10
+
11
+ for (const match of unref(route).matched) {
12
+ if (!match.components) {
13
+ continue;
14
+ }
15
+
16
+ if (name in match.components) {
17
+ return match.components[name];
18
+ }
19
+ }
20
+
21
+ return null;
22
+ });
23
+ }
@@ -0,0 +1,23 @@
1
+ import type { App } from 'vue';
2
+ import { createRouter as createVueRouter, type Router } from 'vue-router';
3
+ import createModalContext from './internal/modalContext';
4
+ import patchRouter from './internal/patchRouter';
5
+ import { modalContextKey } from './symbol';
6
+ import type { RouterOptions } from './types';
7
+
8
+ export default function createRouter(options: RouterOptions): Router {
9
+ const {defaultModal, ...routerOptions} = options;
10
+
11
+ const router = createVueRouter(routerOptions);
12
+ const original = patchRouter(router);
13
+ const ctx = createModalContext(router, original, defaultModal ?? null);
14
+
15
+ const originalInstall = router.install.bind(router);
16
+
17
+ router.install = (app: App): void => {
18
+ originalInstall(app);
19
+ app.provide(modalContextKey, ctx);
20
+ };
21
+
22
+ return router;
23
+ }
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ // note: Side-effect import — registers the `declare module 'vue-router'`
2
+ // augmentations (`RouteLocationOptions.modal`, `RouteMeta.modal`).
3
+ import './augmentations';
4
+
5
+ export * from 'vue-router';
6
+
7
+ // note: Named exports below shadow the star re-export — ES modules
8
+ // resolve direct exports over star re-exports. Our enhanced versions
9
+ // win for `createRouter`, `RouterView`, etc.; every other vue-router
10
+ // symbol flows through unchanged.
11
+ export { default as createRouter } from './createRouter';
12
+ export type { ModalConfig, ModalWrapperProps, RouterOptions, RouterViewProps } from './types';
13
+
14
+ export { default as ModalRouterView } from './component/ModalRouterView';
15
+ export { default as RouterLink } from './component/RouterLink';
16
+ export { default as RouterView } from './component/RouterView';
17
+
18
+ export { default as useIsView } from './composable/useIsView';
19
+ export { default as useModalRoute } from './composable/useModalRoute';
20
+ export { default as useNamedRoute } from './composable/useNamedRoute';
21
+ export { default as useNavigate } from './composable/useNavigate';
22
+ export { default as useRoute, type UseRoute } from './composable/useRoute';
23
+ export { default as useRouteMeta } from './composable/useRouteMeta';
24
+ export { default as useRouteNames } from './composable/useRouteNames';
25
+ export { default as useRouteParam } from './composable/useRouteParam';
26
+ export { default as useRouteView } from './composable/useRouteView';
@@ -0,0 +1,66 @@
1
+ import { type Component, computed, defineComponent, type PropType, provide, type Ref, shallowRef, type VNodeChild } from 'vue';
2
+ import { type RouteLocationNormalized, routerViewLocationKey, viewDepthKey } from 'vue-router';
3
+ import { isInModalKey, routeOverrideKey } from '../symbol';
4
+
5
+ const TRUE_REF = shallowRef(true);
6
+ const FALSE_REF = shallowRef(false);
7
+
8
+ // note: Both providers override `routerViewLocationKey` so descendant
9
+ // `<RouterView>`s render the supplied `route` instead of the router's
10
+ // live currentRoute. `viewDepthKey` is set to the index at which the
11
+ // inner `VueRouterView` should resume in `matched[]` — for
12
+ // `BackgroundProvider`, the host's own depth (so a nested host doesn't
13
+ // re-render its ancestors); for `ModalProvider`, the absolute matched
14
+ // index computed by RouterView from `ctx.depth`.
15
+
16
+ export const BackgroundProvider: Component = defineComponent({
17
+ name: 'BackgroundProvider',
18
+ props: {
19
+ route: {
20
+ type: Object as PropType<RouteLocationNormalized>,
21
+ required: true
22
+ },
23
+ viewDepth: {
24
+ type: Number,
25
+ default: 0
26
+ }
27
+ },
28
+ setup(props, {slots}) {
29
+ const routeRef = computed(() => props.route);
30
+
31
+ provide(routerViewLocationKey, routeRef);
32
+ provide(viewDepthKey, props.viewDepth);
33
+ provide(routeOverrideKey, routeRef);
34
+ provide(isInModalKey, FALSE_REF);
35
+
36
+ return (): VNodeChild => slots.default?.();
37
+ }
38
+ });
39
+
40
+ export const ModalProvider: Component = defineComponent({
41
+ name: 'ModalProvider',
42
+ props: {
43
+ route: {
44
+ type: Object as PropType<RouteLocationNormalized>,
45
+ required: true
46
+ },
47
+ // note: Ref instead of a plain number — Vue's runtime `h()` prop
48
+ // diffing can miss primitive updates when patching in a tight
49
+ // cycle alongside reactive route changes. Ref identity stays
50
+ // stable; `.value` propagates through normal reactivity.
51
+ depthRef: {
52
+ type: Object as PropType<Ref<number>>,
53
+ required: true
54
+ }
55
+ },
56
+ setup(props, {slots}) {
57
+ const routeRef = computed(() => props.route);
58
+
59
+ provide(routerViewLocationKey, routeRef);
60
+ provide(viewDepthKey, props.depthRef);
61
+ provide(routeOverrideKey, routeRef);
62
+ provide(isInModalKey, TRUE_REF);
63
+
64
+ return (): VNodeChild => slots.default?.();
65
+ }
66
+ });