next-modal-router 0.1.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/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +592 -0
- package/assets/next-modal-router-header.png +0 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +547 -0
- package/dist/config.d.ts +22 -0
- package/dist/config.js +64 -0
- package/dist/index.d.ts +91 -0
- package/dist/index.js +166 -0
- package/docs/cli.md +16 -0
- package/docs/concepts.md +17 -0
- package/docs/configuration.md +24 -0
- package/docs/limitations.md +9 -0
- package/docs/nested-overlays.md +13 -0
- package/docs/troubleshooting.md +29 -0
- package/docs/ui-libraries.md +7 -0
- package/package.json +104 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { AnchorHTMLAttributes, ReactNode } from 'react';
|
|
3
|
+
import { LinkProps } from 'next/link';
|
|
4
|
+
import { ReadonlyURLSearchParams } from 'next/navigation';
|
|
5
|
+
|
|
6
|
+
type OverlayLinkProps = LinkProps & Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof LinkProps> & {
|
|
7
|
+
fallback?: string;
|
|
8
|
+
};
|
|
9
|
+
declare const OverlayLink: react.ForwardRefExoticComponent<LinkProps<any> & Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof LinkProps<any>> & {
|
|
10
|
+
fallback?: string;
|
|
11
|
+
} & react.RefAttributes<HTMLAnchorElement>>;
|
|
12
|
+
|
|
13
|
+
type SearchParamValue = string | number | boolean | null | undefined;
|
|
14
|
+
type SearchParamUpdates = Readonly<Record<string, SearchParamValue | readonly SearchParamValue[]>>;
|
|
15
|
+
declare function updateSearchParams(current: URLSearchParams | string, updates: SearchParamUpdates): URLSearchParams;
|
|
16
|
+
declare function withSearchParams(pathname: string, current: URLSearchParams | string, updates: SearchParamUpdates): string;
|
|
17
|
+
|
|
18
|
+
interface OverlayNavigationOptions {
|
|
19
|
+
fallback?: string;
|
|
20
|
+
scroll?: boolean;
|
|
21
|
+
}
|
|
22
|
+
interface OverlayState {
|
|
23
|
+
isOpen: boolean;
|
|
24
|
+
isOverlayNavigation: boolean;
|
|
25
|
+
pathname: string;
|
|
26
|
+
previousPathname?: string;
|
|
27
|
+
depth: number;
|
|
28
|
+
fallback?: string;
|
|
29
|
+
canGoBackSafely: boolean;
|
|
30
|
+
}
|
|
31
|
+
interface OverlayRouter extends OverlayState {
|
|
32
|
+
searchParams: ReadonlyURLSearchParams;
|
|
33
|
+
open(href: string, options?: OverlayNavigationOptions): void;
|
|
34
|
+
replace(href: string, options?: OverlayNavigationOptions): void;
|
|
35
|
+
close(fallback?: string): void;
|
|
36
|
+
back(): void;
|
|
37
|
+
forward(): void;
|
|
38
|
+
refresh(): void;
|
|
39
|
+
setSearchParams(updates: SearchParamUpdates, options?: {
|
|
40
|
+
scroll?: boolean;
|
|
41
|
+
}): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface OverlayRouterProviderProps {
|
|
45
|
+
children: ReactNode;
|
|
46
|
+
defaultFallback?: string;
|
|
47
|
+
restoreFocus?: boolean;
|
|
48
|
+
}
|
|
49
|
+
declare function OverlayRouterProvider({ children, defaultFallback, restoreFocus }: OverlayRouterProviderProps): react.JSX.Element;
|
|
50
|
+
|
|
51
|
+
declare function useOverlayRouter(): OverlayRouter;
|
|
52
|
+
|
|
53
|
+
declare function useOverlayState(): OverlayState;
|
|
54
|
+
|
|
55
|
+
declare const OVERLAY_TYPES: readonly ["modal", "drawer", "sheet", "panel", "custom"];
|
|
56
|
+
type OverlayType = (typeof OVERLAY_TYPES)[number];
|
|
57
|
+
interface OverlayDefinition {
|
|
58
|
+
route: string;
|
|
59
|
+
source: string;
|
|
60
|
+
type: OverlayType;
|
|
61
|
+
slot?: string;
|
|
62
|
+
closeFallback: string;
|
|
63
|
+
}
|
|
64
|
+
interface OverlayConfig<T extends Record<string, OverlayDefinition> = Record<string, OverlayDefinition>> {
|
|
65
|
+
defaultSlot?: string;
|
|
66
|
+
overlays: T;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type RouteSegmentKind = "static" | "dynamic" | "catch-all" | "optional-catch-all" | "group" | "slot";
|
|
70
|
+
interface RouteSegment {
|
|
71
|
+
raw: string;
|
|
72
|
+
kind: RouteSegmentKind;
|
|
73
|
+
name: string;
|
|
74
|
+
contributesToUrl: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type IssueSeverity = "error" | "warning" | "info";
|
|
78
|
+
interface ValidationIssue {
|
|
79
|
+
code: string;
|
|
80
|
+
severity: IssueSeverity;
|
|
81
|
+
message: string;
|
|
82
|
+
path?: string;
|
|
83
|
+
overlay?: string;
|
|
84
|
+
suggestion?: string;
|
|
85
|
+
}
|
|
86
|
+
interface ValidationResult {
|
|
87
|
+
valid: boolean;
|
|
88
|
+
issues: ValidationIssue[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export { type OverlayConfig, type OverlayDefinition, OverlayLink, type OverlayLinkProps, type OverlayNavigationOptions, type OverlayRouter, OverlayRouterProvider, type OverlayRouterProviderProps, type OverlayState, type OverlayType, type RouteSegment, type RouteSegmentKind, type SearchParamUpdates, type SearchParamValue, type ValidationIssue, type ValidationResult, updateSearchParams, useOverlayRouter, useOverlayState, withSearchParams };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/runtime/overlay-link.tsx
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import { forwardRef } from "react";
|
|
6
|
+
|
|
7
|
+
// src/runtime/context.tsx
|
|
8
|
+
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
|
9
|
+
import { usePathname } from "next/navigation";
|
|
10
|
+
import { jsx } from "react/jsx-runtime";
|
|
11
|
+
var OverlayContext = createContext(null);
|
|
12
|
+
function pathnameOf(href) {
|
|
13
|
+
try {
|
|
14
|
+
return new URL(href, "http://nmr.local").pathname;
|
|
15
|
+
} catch {
|
|
16
|
+
return href.split("?")[0] ?? href;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function activeEntries(entries, pathname) {
|
|
20
|
+
let index = -1;
|
|
21
|
+
for (let cursor = entries.length - 1; cursor >= 0; cursor -= 1) {
|
|
22
|
+
if (entries[cursor]?.pathname === pathname) {
|
|
23
|
+
index = cursor;
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return index < 0 ? [] : entries.slice(0, index + 1);
|
|
28
|
+
}
|
|
29
|
+
function OverlayRouterProvider({ children, defaultFallback, restoreFocus = true }) {
|
|
30
|
+
const pathname = usePathname();
|
|
31
|
+
const [entries, setEntries] = useState([]);
|
|
32
|
+
const previous = useRef(void 0);
|
|
33
|
+
const lastPath = useRef(pathname);
|
|
34
|
+
if (lastPath.current !== pathname) {
|
|
35
|
+
previous.current = lastPath.current;
|
|
36
|
+
lastPath.current = pathname;
|
|
37
|
+
}
|
|
38
|
+
const mark = useCallback((href, fallback) => {
|
|
39
|
+
const active2 = typeof document === "undefined" ? void 0 : document.activeElement instanceof HTMLElement ? document.activeElement : void 0;
|
|
40
|
+
const resolvedFallback = fallback ?? defaultFallback;
|
|
41
|
+
setEntries((current) => [...activeEntries(current, pathname), { href, pathname: pathnameOf(href), ...resolvedFallback ? { fallback: resolvedFallback } : {}, ...active2 ? { trigger: active2 } : {} }]);
|
|
42
|
+
}, [defaultFallback, pathname]);
|
|
43
|
+
const replaceMark = useCallback((href, fallback) => {
|
|
44
|
+
setEntries((current) => {
|
|
45
|
+
const active2 = activeEntries(current, pathname);
|
|
46
|
+
const resolvedFallback = fallback ?? active2.at(-1)?.fallback ?? defaultFallback;
|
|
47
|
+
return [...active2.slice(0, -1), { href, pathname: pathnameOf(href), ...resolvedFallback ? { fallback: resolvedFallback } : {} }];
|
|
48
|
+
});
|
|
49
|
+
}, [defaultFallback, pathname]);
|
|
50
|
+
const pop = useCallback(() => {
|
|
51
|
+
const removed = activeEntries(entries, pathname).at(-1);
|
|
52
|
+
if (restoreFocus) queueMicrotask(() => removed?.trigger?.isConnected && removed.trigger.focus());
|
|
53
|
+
return removed;
|
|
54
|
+
}, [entries, pathname, restoreFocus]);
|
|
55
|
+
const active = activeEntries(entries, pathname);
|
|
56
|
+
const top = active.at(-1);
|
|
57
|
+
const value = useMemo(() => ({
|
|
58
|
+
isOpen: active.length > 0,
|
|
59
|
+
isOverlayNavigation: active.length > 0,
|
|
60
|
+
pathname,
|
|
61
|
+
...previous.current ? { previousPathname: previous.current } : {},
|
|
62
|
+
depth: active.length,
|
|
63
|
+
...top?.fallback ? { fallback: top.fallback } : {},
|
|
64
|
+
canGoBackSafely: Boolean(top && top.pathname === pathname),
|
|
65
|
+
mark,
|
|
66
|
+
replaceMark,
|
|
67
|
+
pop
|
|
68
|
+
}), [active.length, mark, pathname, pop, replaceMark, top]);
|
|
69
|
+
return /* @__PURE__ */ jsx(OverlayContext.Provider, { value, children });
|
|
70
|
+
}
|
|
71
|
+
function useOverlayContext() {
|
|
72
|
+
const context = useContext(OverlayContext);
|
|
73
|
+
if (!context) throw new Error("next-modal-router hooks and OverlayLink must be rendered inside OverlayRouterProvider.");
|
|
74
|
+
return context;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/runtime/overlay-link.tsx
|
|
78
|
+
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
79
|
+
function hrefString(href) {
|
|
80
|
+
if (typeof href === "string") return href;
|
|
81
|
+
const query = href.query ? new URLSearchParams(Object.entries(href.query).flatMap(([key, value]) => Array.isArray(value) ? value.map((item) => [key, String(item)]) : value == null ? [] : [[key, String(value)]])).toString() : "";
|
|
82
|
+
return `${href.pathname ?? ""}${query ? `?${query}` : ""}${href.hash ?? ""}`;
|
|
83
|
+
}
|
|
84
|
+
var OverlayLink = forwardRef(function OverlayLink2({ fallback, onClick, scroll = false, ...props }, ref) {
|
|
85
|
+
const context = useOverlayContext();
|
|
86
|
+
const handleClick = (event) => {
|
|
87
|
+
onClick?.(event);
|
|
88
|
+
if (!event.defaultPrevented && event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) context.mark(hrefString(props.href), fallback);
|
|
89
|
+
};
|
|
90
|
+
return /* @__PURE__ */ jsx2(Link, { ...props, ref, scroll, onClick: handleClick });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// src/runtime/use-overlay-router.ts
|
|
94
|
+
import { useCallback as useCallback2, useMemo as useMemo2 } from "react";
|
|
95
|
+
import { useRouter, useSearchParams } from "next/navigation";
|
|
96
|
+
|
|
97
|
+
// src/runtime/history.ts
|
|
98
|
+
function decideSafeClose(input) {
|
|
99
|
+
if (input.depth > 0 && input.entryPathname === input.currentPathname) return { action: "back" };
|
|
100
|
+
if (input.fallback) return { action: "replace", href: input.fallback };
|
|
101
|
+
return { action: "replace", href: "/" };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// src/runtime/search-params.ts
|
|
105
|
+
function updateSearchParams(current, updates) {
|
|
106
|
+
const result = new URLSearchParams(current);
|
|
107
|
+
for (const [key, raw] of Object.entries(updates)) {
|
|
108
|
+
result.delete(key);
|
|
109
|
+
const values = Array.isArray(raw) ? raw : [raw];
|
|
110
|
+
for (const value of values) if (value !== null && value !== void 0) result.append(key, String(value));
|
|
111
|
+
}
|
|
112
|
+
result.sort();
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
function withSearchParams(pathname, current, updates) {
|
|
116
|
+
const query = updateSearchParams(current, updates).toString();
|
|
117
|
+
return query ? `${pathname}?${query}` : pathname;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// src/runtime/use-overlay-router.ts
|
|
121
|
+
function useOverlayRouter() {
|
|
122
|
+
const router = useRouter();
|
|
123
|
+
const searchParams = useSearchParams();
|
|
124
|
+
const context = useOverlayContext();
|
|
125
|
+
const open = useCallback2((href, options) => {
|
|
126
|
+
context.mark(href, options?.fallback);
|
|
127
|
+
router.push(href, { scroll: options?.scroll ?? false });
|
|
128
|
+
}, [context, router]);
|
|
129
|
+
const replace = useCallback2((href, options) => {
|
|
130
|
+
context.replaceMark(href, options?.fallback);
|
|
131
|
+
router.replace(href, { scroll: options?.scroll ?? false });
|
|
132
|
+
}, [context, router]);
|
|
133
|
+
const close = useCallback2((fallback) => {
|
|
134
|
+
const resolvedFallback = fallback ?? context.fallback;
|
|
135
|
+
const decision = decideSafeClose({ depth: context.depth, currentPathname: context.pathname, ...context.canGoBackSafely ? { entryPathname: context.pathname } : {}, ...resolvedFallback ? { fallback: resolvedFallback } : {} });
|
|
136
|
+
context.pop();
|
|
137
|
+
if (decision.action === "back") router.back();
|
|
138
|
+
else router.replace(decision.href, { scroll: false });
|
|
139
|
+
}, [context, router]);
|
|
140
|
+
const setSearchParams = useCallback2((updates, options) => {
|
|
141
|
+
router.replace(withSearchParams(context.pathname, searchParams.toString(), updates), { scroll: options?.scroll ?? false });
|
|
142
|
+
}, [context.pathname, router, searchParams]);
|
|
143
|
+
return useMemo2(() => ({ ...context, searchParams, open, replace, close, back: router.back, forward: router.forward, refresh: router.refresh, setSearchParams }), [close, context, open, replace, router.back, router.forward, router.refresh, searchParams, setSearchParams]);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// src/runtime/use-overlay-state.ts
|
|
147
|
+
function useOverlayState() {
|
|
148
|
+
const context = useOverlayContext();
|
|
149
|
+
return {
|
|
150
|
+
isOpen: context.isOpen,
|
|
151
|
+
isOverlayNavigation: context.isOverlayNavigation,
|
|
152
|
+
pathname: context.pathname,
|
|
153
|
+
...context.previousPathname ? { previousPathname: context.previousPathname } : {},
|
|
154
|
+
depth: context.depth,
|
|
155
|
+
...context.fallback ? { fallback: context.fallback } : {},
|
|
156
|
+
canGoBackSafely: context.canGoBackSafely
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
export {
|
|
160
|
+
OverlayLink,
|
|
161
|
+
OverlayRouterProvider,
|
|
162
|
+
updateSearchParams,
|
|
163
|
+
useOverlayRouter,
|
|
164
|
+
useOverlayState,
|
|
165
|
+
withSearchParams
|
|
166
|
+
};
|
package/docs/cli.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# CLI reference
|
|
2
|
+
|
|
3
|
+
The `next-modal-router` binary has five commands: `init`, `add`, `check`, `doctor`, and `list`. Run `next-modal-router help` for compact syntax.
|
|
4
|
+
|
|
5
|
+
Generation commands support `--dry-run` and refuse meaningful overwrites unless the requested operation uses `--force`. Analysis commands support `--format json`; JSON contains no ANSI formatting. `--cwd` targets an application inside a monorepo.
|
|
6
|
+
|
|
7
|
+
Exit codes are `0` for success, `1` for route validation errors, and `2` for invalid invocation, unreadable configuration, or protected-file conflicts.
|
|
8
|
+
|
|
9
|
+
## Automation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
next-modal-router add product --route /products/[id] --source /products --type modal --slot modal --fallback /products --ci
|
|
13
|
+
next-modal-router check --format json --ci
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Quote dynamic routes in shells that expand brackets. See the README for every command, flag, and example output.
|
package/docs/concepts.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Concepts
|
|
2
|
+
|
|
3
|
+
`next-modal-router` separates three responsibilities. Next.js owns URL matching, server rendering, soft navigation, and browser history. The CLI owns filesystem generation and static validation. The runtime records only overlay navigation initiated through its public API.
|
|
4
|
+
|
|
5
|
+
## Parallel slots
|
|
6
|
+
|
|
7
|
+
A directory named `@modal` becomes a named layout prop. Its `default.tsx` must return `null` when no intercepted child is active. The slot does not add a URL segment.
|
|
8
|
+
|
|
9
|
+
## Interception
|
|
10
|
+
|
|
11
|
+
`(.)products/[id]` targets a sibling route segment from the layout that owns the slot. `(..)` climbs one route segment, repeated forms climb multiple segments, and `(...)` targets from the app root. Route groups and parallel slots do not count as URL segments.
|
|
12
|
+
|
|
13
|
+
## Navigation modes
|
|
14
|
+
|
|
15
|
+
Soft navigation can preserve the current page and render the target through a slot. A refresh or direct request renders the ordinary target page. Both modes intentionally share the same URL.
|
|
16
|
+
|
|
17
|
+
The library never attempts to turn a hard navigation into a client-only modal.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Configuration
|
|
2
|
+
|
|
3
|
+
The generated `next-modal-router.config.ts` uses `defineConfig` for literal inference. `.mts`, `.js`, and `.mjs` are also discovered.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { defineConfig } from "next-modal-router/config"
|
|
7
|
+
|
|
8
|
+
export default defineConfig({
|
|
9
|
+
defaultSlot: "modal",
|
|
10
|
+
overlays: {
|
|
11
|
+
product: {
|
|
12
|
+
route: "/products/[id]",
|
|
13
|
+
source: "/products",
|
|
14
|
+
type: "modal",
|
|
15
|
+
slot: "modal",
|
|
16
|
+
closeFallback: "/products",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
})
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`route`, `source`, and `closeFallback` are absolute application routes. `slot` omits the leading `@`. `type` is one of `modal`, `drawer`, `sheet`, `panel`, or `custom` and has no visual runtime behavior. `defaultSlot` applies when a definition omits `slot`.
|
|
23
|
+
|
|
24
|
+
Config files execute in the local development process, like Next.js config. The loader does not evaluate strings or accept remote configuration. Keep configuration deterministic for CI.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Limitations
|
|
2
|
+
|
|
3
|
+
`next-modal-router` orchestrates documented App Router behavior; it cannot alter when Next.js chooses an intercepted route.
|
|
4
|
+
|
|
5
|
+
Browser APIs do not disclose arbitrary history destinations. Safe close therefore trusts only entries recorded by the current provider and otherwise replaces with a fallback. Navigations made through raw custom router calls are invisible to this marker.
|
|
6
|
+
|
|
7
|
+
The generator targets root-owned parallel slots. The analyzer can discover nested existing slots, but unusual route trees may be maintained manually. Config-driven `add` serializes configuration to a stable static form and does not preserve comments or computed expressions.
|
|
8
|
+
|
|
9
|
+
There is no focus trap, portal, animation engine, dialog markup, CSS, or server-side overlay state. Use an accessible UI library for those concerns.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Nested overlays
|
|
2
|
+
|
|
3
|
+
Nested navigation remains URL-first:
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
/products
|
|
7
|
+
/products/42
|
|
8
|
+
/products/42/reviews
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Open each level through `OverlayLink` or `overlay.open`. The provider then reports depth `0`, `1`, and `2`, and safe close uses one native back operation per recorded current level.
|
|
12
|
+
|
|
13
|
+
Visual stacking depends on the route tree. A second parallel slot can preserve two independent surfaces. When one slot replaces its own active page, render the parent modal shell in the deeper intercepted page if the design needs both surfaces visible. Do not build a detached local array of modal components: it will diverge from refresh, sharing, and browser history.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Troubleshooting
|
|
2
|
+
|
|
3
|
+
## Modal remains visible after navigation
|
|
4
|
+
|
|
5
|
+
The slot may be retaining its previous active segment. Add `@slot/default.tsx` returning `null`, and ensure the close action navigates rather than only toggling component state.
|
|
6
|
+
|
|
7
|
+
## Missing `default.tsx`
|
|
8
|
+
|
|
9
|
+
Every parallel slot needs a fallback for routes that do not match it. Run `next-modal-router check`; `NMR001` identifies the slot path.
|
|
10
|
+
|
|
11
|
+
## Incorrect intercept depth
|
|
12
|
+
|
|
13
|
+
Matchers count route segments. They do not count route groups such as `(shop)` or parallel slots such as `@modal`. Run `check --verbose` to compare found and expected paths.
|
|
14
|
+
|
|
15
|
+
## Overlay appears on refresh
|
|
16
|
+
|
|
17
|
+
The modal may live in an ordinary page or shared layout. Intercepted UI belongs under `@slot/(.)target`; the normal target page belongs outside the slot.
|
|
18
|
+
|
|
19
|
+
## Overlay does not appear during soft navigation
|
|
20
|
+
|
|
21
|
+
Confirm the slot is rendered by its owner layout and use `OverlayLink`, Next.js `Link`, or App Router navigation. A plain anchor can cause a hard request.
|
|
22
|
+
|
|
23
|
+
## Back button leaves the website
|
|
24
|
+
|
|
25
|
+
Replace unconditional `router.back()` with `overlay.close()` and provide a route fallback. Direct requests have no safe in-app previous entry the browser will disclose.
|
|
26
|
+
|
|
27
|
+
## Parallel slot produces 404
|
|
28
|
+
|
|
29
|
+
Pass the named slot prop through its layout, add a default route, and verify that the intercepted page filename is one of Next.js's supported page extensions.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# UI-library integrations
|
|
2
|
+
|
|
3
|
+
Routing controls whether the intercepted page exists. A UI library controls portal placement, focus trapping, escape handling, outside-click handling, animation, and visuals.
|
|
4
|
+
|
|
5
|
+
For Radix or shadcn/ui, render the dialog in controlled mode with `open` and call `overlay.close()` when `onOpenChange` receives `false`. For Headless UI, pass `open` and `onClose={overlay.close}`. For sheet packages, use the equivalent controlled-open callback.
|
|
6
|
+
|
|
7
|
+
Avoid a second persistent boolean as the source of truth. Navigation unmounts the intercepted route after close. If the UI library restores focus, set `restoreFocus={false}` on `OverlayRouterProvider` so only one system owns that behavior.
|
package/package.json
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "next-modal-router",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "URL-native modals, drawers and overlays for Next.js App Router — without the routing headache.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Ali Ranjbar",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/RanjbarAli/next-modal-router.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/RanjbarAli/next-modal-router#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/RanjbarAli/next-modal-router/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"nextjs",
|
|
18
|
+
"next",
|
|
19
|
+
"react",
|
|
20
|
+
"modal",
|
|
21
|
+
"router",
|
|
22
|
+
"app-router",
|
|
23
|
+
"parallel-routes",
|
|
24
|
+
"intercepting-routes",
|
|
25
|
+
"drawer",
|
|
26
|
+
"sheet",
|
|
27
|
+
"overlay",
|
|
28
|
+
"routing"
|
|
29
|
+
],
|
|
30
|
+
"sideEffects": false,
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"assets",
|
|
34
|
+
"docs",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE",
|
|
37
|
+
"CHANGELOG.md",
|
|
38
|
+
"package.json"
|
|
39
|
+
],
|
|
40
|
+
"main": "./dist/index.js",
|
|
41
|
+
"types": "./dist/index.d.ts",
|
|
42
|
+
"exports": {
|
|
43
|
+
".": {
|
|
44
|
+
"types": "./dist/index.d.ts",
|
|
45
|
+
"import": "./dist/index.js"
|
|
46
|
+
},
|
|
47
|
+
"./config": {
|
|
48
|
+
"types": "./dist/config.d.ts",
|
|
49
|
+
"import": "./dist/config.js"
|
|
50
|
+
},
|
|
51
|
+
"./cli": {
|
|
52
|
+
"types": "./dist/cli.d.ts",
|
|
53
|
+
"import": "./dist/cli.js"
|
|
54
|
+
},
|
|
55
|
+
"./package.json": "./package.json"
|
|
56
|
+
},
|
|
57
|
+
"bin": {
|
|
58
|
+
"next-modal-router": "dist/cli.js"
|
|
59
|
+
},
|
|
60
|
+
"engines": {
|
|
61
|
+
"node": ">=18.17"
|
|
62
|
+
},
|
|
63
|
+
"packageManager": "pnpm@10.15.0",
|
|
64
|
+
"peerDependencies": {
|
|
65
|
+
"next": ">=14.2 <17",
|
|
66
|
+
"react": ">=18.2 <20",
|
|
67
|
+
"react-dom": ">=18.2 <20"
|
|
68
|
+
},
|
|
69
|
+
"scripts": {
|
|
70
|
+
"build": "tsup",
|
|
71
|
+
"dev": "tsup --watch",
|
|
72
|
+
"lint": "eslint .",
|
|
73
|
+
"typecheck": "tsc --noEmit && pnpm build && pnpm --dir examples/basic typecheck",
|
|
74
|
+
"test": "vitest run",
|
|
75
|
+
"test:watch": "vitest",
|
|
76
|
+
"test:coverage": "vitest run --coverage",
|
|
77
|
+
"test:e2e": "playwright test",
|
|
78
|
+
"example:build": "pnpm --dir examples/basic build",
|
|
79
|
+
"check": "pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm example:build",
|
|
80
|
+
"prepublishOnly": "pnpm check"
|
|
81
|
+
},
|
|
82
|
+
"dependencies": {
|
|
83
|
+
"jiti": "^2.5.1"
|
|
84
|
+
},
|
|
85
|
+
"devDependencies": {
|
|
86
|
+
"@eslint/js": "^9.35.0",
|
|
87
|
+
"@playwright/test": "^1.55.0",
|
|
88
|
+
"@testing-library/react": "^16.3.0",
|
|
89
|
+
"@types/node": "^22.18.0",
|
|
90
|
+
"@types/react": "^19.1.12",
|
|
91
|
+
"@types/react-dom": "^19.1.9",
|
|
92
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
93
|
+
"eslint": "^9.35.0",
|
|
94
|
+
"eslint-plugin-react-hooks": "^5.2.0",
|
|
95
|
+
"globals": "^16.3.0",
|
|
96
|
+
"next": "16.3.3",
|
|
97
|
+
"react": "19.2.8",
|
|
98
|
+
"react-dom": "19.2.8",
|
|
99
|
+
"tsup": "^8.5.0",
|
|
100
|
+
"typescript": "^5.9.2",
|
|
101
|
+
"typescript-eslint": "^8.42.0",
|
|
102
|
+
"vitest": "^3.2.4"
|
|
103
|
+
}
|
|
104
|
+
}
|