@esmx/router-react 3.0.0-rc.105
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 +515 -0
- package/dist/context.d.ts +85 -0
- package/dist/context.mjs +23 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.mjs +11 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.mjs +169 -0
- package/dist/router-link.d.ts +35 -0
- package/dist/router-link.mjs +85 -0
- package/dist/router-provider.d.ts +61 -0
- package/dist/router-provider.mjs +34 -0
- package/dist/router-view.d.ts +63 -0
- package/dist/router-view.mjs +40 -0
- package/dist/types.d.ts +27 -0
- package/dist/types.mjs +0 -0
- package/dist/use-link.d.ts +97 -0
- package/dist/use-link.mjs +40 -0
- package/dist/util.d.ts +13 -0
- package/dist/util.mjs +15 -0
- package/package.json +85 -0
- package/src/context.ts +109 -0
- package/src/index.test.ts +215 -0
- package/src/index.ts +21 -0
- package/src/router-link.ts +222 -0
- package/src/router-provider.ts +104 -0
- package/src/router-view.ts +113 -0
- package/src/types.ts +30 -0
- package/src/use-link.ts +138 -0
- package/src/util.ts +38 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { Route } from '@esmx/router';
|
|
2
|
+
import { createElement, useCallback, useSyncExternalStore } from 'react';
|
|
3
|
+
import { RouterContext } from './context';
|
|
4
|
+
import type { RouterContextValue, RouterProviderProps } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* RouterProvider component that provides router context to the React tree.
|
|
8
|
+
* This must wrap your application to enable routing functionality.
|
|
9
|
+
* Uses useSyncExternalStore for optimal React 18+ integration with concurrent features.
|
|
10
|
+
*
|
|
11
|
+
* @param props - Component props
|
|
12
|
+
* @param props.router - Router instance to provide
|
|
13
|
+
* @param props.children - Child components
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* import { Router, RouterMode } from '@esmx/router';
|
|
18
|
+
* import { RouterProvider } from '@esmx/router-react';
|
|
19
|
+
*
|
|
20
|
+
* const routes = [
|
|
21
|
+
* { path: '/', component: Home },
|
|
22
|
+
* { path: '/about', component: About }
|
|
23
|
+
* ];
|
|
24
|
+
*
|
|
25
|
+
* const router = new Router({
|
|
26
|
+
* routes,
|
|
27
|
+
* mode: RouterMode.history
|
|
28
|
+
* });
|
|
29
|
+
*
|
|
30
|
+
* function App() {
|
|
31
|
+
* return (
|
|
32
|
+
* <RouterProvider router={router}>
|
|
33
|
+
* <Layout>
|
|
34
|
+
* <RouterView />
|
|
35
|
+
* </Layout>
|
|
36
|
+
* </RouterProvider>
|
|
37
|
+
* );
|
|
38
|
+
* }
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```tsx
|
|
43
|
+
* // SSR usage - initialize router with server URL
|
|
44
|
+
* import { Router, RouterMode } from '@esmx/router';
|
|
45
|
+
* import { RouterProvider } from '@esmx/router-react';
|
|
46
|
+
*
|
|
47
|
+
* const router = new Router({
|
|
48
|
+
* routes,
|
|
49
|
+
* mode: RouterMode.history,
|
|
50
|
+
* base: new URL(serverUrl)
|
|
51
|
+
* });
|
|
52
|
+
*
|
|
53
|
+
* function ServerApp() {
|
|
54
|
+
* return (
|
|
55
|
+
* <RouterProvider router={router}>
|
|
56
|
+
* <App />
|
|
57
|
+
* </RouterProvider>
|
|
58
|
+
* );
|
|
59
|
+
* }
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export function RouterProvider({
|
|
63
|
+
router,
|
|
64
|
+
children
|
|
65
|
+
}: RouterProviderProps): React.ReactElement {
|
|
66
|
+
// Subscribe to route changes using useSyncExternalStore
|
|
67
|
+
// This ensures proper integration with React 18's concurrent features
|
|
68
|
+
const subscribe = useCallback(
|
|
69
|
+
(callback: () => void) => {
|
|
70
|
+
return router.afterEach(callback);
|
|
71
|
+
},
|
|
72
|
+
[router]
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const getSnapshot = useCallback((): Route => {
|
|
76
|
+
return router.route;
|
|
77
|
+
}, [router]);
|
|
78
|
+
|
|
79
|
+
const getServerSnapshot = useCallback((): Route => {
|
|
80
|
+
return router.route;
|
|
81
|
+
}, [router]);
|
|
82
|
+
|
|
83
|
+
// Subscribe to route changes with useSyncExternalStore for concurrent mode safety
|
|
84
|
+
const route = useSyncExternalStore(
|
|
85
|
+
subscribe,
|
|
86
|
+
getSnapshot,
|
|
87
|
+
getServerSnapshot
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// Create stable context value
|
|
91
|
+
const contextValue: RouterContextValue = {
|
|
92
|
+
router,
|
|
93
|
+
route
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Use createElement instead of JSX
|
|
97
|
+
return createElement(
|
|
98
|
+
RouterContext.Provider,
|
|
99
|
+
{ value: contextValue },
|
|
100
|
+
children
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
RouterProvider.displayName = 'RouterProvider';
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ComponentType,
|
|
3
|
+
createElement,
|
|
4
|
+
isValidElement,
|
|
5
|
+
type ReactElement,
|
|
6
|
+
useMemo
|
|
7
|
+
} from 'react';
|
|
8
|
+
import {
|
|
9
|
+
RouterViewDepthContext,
|
|
10
|
+
useRoute,
|
|
11
|
+
useRouterViewDepth
|
|
12
|
+
} from './context';
|
|
13
|
+
import type { RouterViewProps } from './types';
|
|
14
|
+
import { resolveComponent } from './util';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* RouterView component that renders the matched route component.
|
|
18
|
+
* Acts as a placeholder where route components are rendered based on the current route.
|
|
19
|
+
* Supports nested routing with automatic depth tracking.
|
|
20
|
+
*
|
|
21
|
+
* @param props - Component props
|
|
22
|
+
* @param props.fallback - Optional fallback component when no route matches
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```tsx
|
|
26
|
+
* // Basic usage
|
|
27
|
+
* import { RouterView } from '@esmx/router-react';
|
|
28
|
+
*
|
|
29
|
+
* function App() {
|
|
30
|
+
* return (
|
|
31
|
+
* <div>
|
|
32
|
+
* <nav>
|
|
33
|
+
* <RouterLink to="/">Home</RouterLink>
|
|
34
|
+
* <RouterLink to="/about">About</RouterLink>
|
|
35
|
+
* </nav>
|
|
36
|
+
* <RouterView />
|
|
37
|
+
* </div>
|
|
38
|
+
* );
|
|
39
|
+
* }
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```tsx
|
|
44
|
+
* // Nested routing
|
|
45
|
+
* // Routes: [
|
|
46
|
+
* // { path: '/users', component: UsersLayout, children: [
|
|
47
|
+
* // { path: ':id', component: UserProfile }
|
|
48
|
+
* // ]}
|
|
49
|
+
* // ]
|
|
50
|
+
*
|
|
51
|
+
* function UsersLayout() {
|
|
52
|
+
* return (
|
|
53
|
+
* <div>
|
|
54
|
+
* <h1>Users</h1>
|
|
55
|
+
* <RouterView /> // Renders UserProfile for /users/:id
|
|
56
|
+
* </div>
|
|
57
|
+
* );
|
|
58
|
+
* }
|
|
59
|
+
* ```
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```tsx
|
|
63
|
+
* // With fallback
|
|
64
|
+
* import { RouterView } from '@esmx/router-react';
|
|
65
|
+
*
|
|
66
|
+
* function App() {
|
|
67
|
+
* return (
|
|
68
|
+
* <RouterView fallback={<div>Page not found</div>} />
|
|
69
|
+
* );
|
|
70
|
+
* }
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export function RouterView({ fallback }: RouterViewProps): ReactElement | null {
|
|
74
|
+
const route = useRoute();
|
|
75
|
+
const depth = useRouterViewDepth();
|
|
76
|
+
|
|
77
|
+
// Get the matched route at current depth
|
|
78
|
+
const matchedRoute = route.matched[depth];
|
|
79
|
+
|
|
80
|
+
// Resolve the component from the matched route
|
|
81
|
+
const Component = useMemo(() => {
|
|
82
|
+
if (!matchedRoute?.component) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return resolveComponent(matchedRoute.component) as ComponentType<any>;
|
|
86
|
+
}, [matchedRoute?.component]);
|
|
87
|
+
|
|
88
|
+
// Render fallback if no component found
|
|
89
|
+
if (!Component) {
|
|
90
|
+
if (fallback) {
|
|
91
|
+
// If fallback is already a ReactElement, return it
|
|
92
|
+
if (isValidElement(fallback)) {
|
|
93
|
+
return fallback;
|
|
94
|
+
}
|
|
95
|
+
// If fallback is a component, render it
|
|
96
|
+
if (typeof fallback === 'function') {
|
|
97
|
+
return createElement(fallback as ComponentType);
|
|
98
|
+
}
|
|
99
|
+
// Return null for other cases (shouldn't happen with proper typing)
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Provide incremented depth for nested RouterViews using createElement
|
|
106
|
+
return createElement(
|
|
107
|
+
RouterViewDepthContext.Provider,
|
|
108
|
+
{ value: depth + 1 },
|
|
109
|
+
createElement(Component, { key: matchedRoute.compilePath })
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
RouterView.displayName = 'RouterView';
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Route, Router } from '@esmx/router';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Interface for the router context value.
|
|
5
|
+
* Contains the router instance and current route.
|
|
6
|
+
*/
|
|
7
|
+
export interface RouterContextValue {
|
|
8
|
+
/** Router instance for navigation */
|
|
9
|
+
router: Router;
|
|
10
|
+
/** Current route object */
|
|
11
|
+
route: Route;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Props for the RouterProvider component.
|
|
16
|
+
*/
|
|
17
|
+
export interface RouterProviderProps {
|
|
18
|
+
/** Router instance to provide to child components */
|
|
19
|
+
router: Router;
|
|
20
|
+
/** Child components */
|
|
21
|
+
children: React.ReactNode;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Props for the RouterView component.
|
|
26
|
+
*/
|
|
27
|
+
export interface RouterViewProps {
|
|
28
|
+
/** Optional fallback component to render when no route matches */
|
|
29
|
+
fallback?: React.ComponentType | React.ReactNode;
|
|
30
|
+
}
|
package/src/use-link.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { RouterLinkProps, RouterLinkResolved } from '@esmx/router';
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
import { useRouter } from './context';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hook to create reactive link helpers for custom navigation components.
|
|
7
|
+
* Returns a resolved link object with attributes, state, and event handlers.
|
|
8
|
+
*
|
|
9
|
+
* This hook is useful when you need to build custom link components
|
|
10
|
+
* with full control over rendering while retaining router functionality.
|
|
11
|
+
*
|
|
12
|
+
* @param props - RouterLink properties
|
|
13
|
+
* @returns Resolved link object with attributes, state, and navigation methods
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* import { useLink } from '@esmx/router-react';
|
|
18
|
+
*
|
|
19
|
+
* function CustomNavButton({ to, children }) {
|
|
20
|
+
* const link = useLink({ to, type: 'push', exact: 'include' });
|
|
21
|
+
*
|
|
22
|
+
* return (
|
|
23
|
+
* <button
|
|
24
|
+
* onClick={(e) => link.navigate(e)}
|
|
25
|
+
* className={link.isActive ? 'active' : ''}
|
|
26
|
+
* disabled={link.isExactActive}
|
|
27
|
+
* >
|
|
28
|
+
* {children}
|
|
29
|
+
* {link.isActive && <span>✓</span>}
|
|
30
|
+
* </button>
|
|
31
|
+
* );
|
|
32
|
+
* }
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```tsx
|
|
37
|
+
* // Building a custom navigation card
|
|
38
|
+
* import { useLink } from '@esmx/router-react';
|
|
39
|
+
*
|
|
40
|
+
* interface NavCardProps {
|
|
41
|
+
* to: string;
|
|
42
|
+
* title: string;
|
|
43
|
+
* description: string;
|
|
44
|
+
* icon: React.ReactNode;
|
|
45
|
+
* }
|
|
46
|
+
*
|
|
47
|
+
* function NavCard({ to, title, description, icon }: NavCardProps) {
|
|
48
|
+
* const link = useLink({ to });
|
|
49
|
+
*
|
|
50
|
+
* return (
|
|
51
|
+
* <div
|
|
52
|
+
* className={`nav-card ${link.isActive ? 'active' : ''}`}
|
|
53
|
+
* onClick={(e) => link.navigate(e)}
|
|
54
|
+
* role="link"
|
|
55
|
+
* tabIndex={0}
|
|
56
|
+
* >
|
|
57
|
+
* <div className="icon">{icon}</div>
|
|
58
|
+
* <h3>{title}</h3>
|
|
59
|
+
* <p>{description}</p>
|
|
60
|
+
* {link.isExternal && <span className="external-badge">↗</span>}
|
|
61
|
+
* </div>
|
|
62
|
+
* );
|
|
63
|
+
* }
|
|
64
|
+
* ```
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```tsx
|
|
68
|
+
* // Using all resolved properties
|
|
69
|
+
* import { useLink } from '@esmx/router-react';
|
|
70
|
+
*
|
|
71
|
+
* function DebugLink({ to }) {
|
|
72
|
+
* const link = useLink({ to, exact: 'exact' });
|
|
73
|
+
*
|
|
74
|
+
* return (
|
|
75
|
+
* <div>
|
|
76
|
+
* <a
|
|
77
|
+
* href={link.attributes.href}
|
|
78
|
+
* target={link.attributes.target}
|
|
79
|
+
* rel={link.attributes.rel}
|
|
80
|
+
* className={link.attributes.class}
|
|
81
|
+
* onClick={(e) => {
|
|
82
|
+
* e.preventDefault();
|
|
83
|
+
* link.navigate(e);
|
|
84
|
+
* }}
|
|
85
|
+
* >
|
|
86
|
+
* Link Text
|
|
87
|
+
* </a>
|
|
88
|
+
* <pre>
|
|
89
|
+
* isActive: {String(link.isActive)}
|
|
90
|
+
* isExactActive: {String(link.isExactActive)}
|
|
91
|
+
* isExternal: {String(link.isExternal)}
|
|
92
|
+
* type: {link.type}
|
|
93
|
+
* tag: {link.tag}
|
|
94
|
+
* </pre>
|
|
95
|
+
* </div>
|
|
96
|
+
* );
|
|
97
|
+
* }
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
export function useLink(props: RouterLinkProps): RouterLinkResolved {
|
|
101
|
+
const router = useRouter();
|
|
102
|
+
const {
|
|
103
|
+
to,
|
|
104
|
+
type,
|
|
105
|
+
replace,
|
|
106
|
+
exact,
|
|
107
|
+
activeClass,
|
|
108
|
+
event,
|
|
109
|
+
tag,
|
|
110
|
+
layerOptions,
|
|
111
|
+
beforeNavigate
|
|
112
|
+
} = props;
|
|
113
|
+
|
|
114
|
+
return useMemo(() => {
|
|
115
|
+
return router.resolveLink({
|
|
116
|
+
to,
|
|
117
|
+
type,
|
|
118
|
+
replace,
|
|
119
|
+
exact,
|
|
120
|
+
activeClass,
|
|
121
|
+
event,
|
|
122
|
+
tag,
|
|
123
|
+
layerOptions,
|
|
124
|
+
beforeNavigate
|
|
125
|
+
});
|
|
126
|
+
}, [
|
|
127
|
+
router,
|
|
128
|
+
to,
|
|
129
|
+
type,
|
|
130
|
+
replace,
|
|
131
|
+
exact,
|
|
132
|
+
activeClass,
|
|
133
|
+
event,
|
|
134
|
+
tag,
|
|
135
|
+
layerOptions,
|
|
136
|
+
beforeNavigate
|
|
137
|
+
]);
|
|
138
|
+
}
|
package/src/util.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if a value is an ES module.
|
|
3
|
+
* @param obj - The object to check
|
|
4
|
+
* @returns True if the object is an ES module
|
|
5
|
+
*/
|
|
6
|
+
export function isESModule(obj: unknown): obj is Record<string | symbol, any> {
|
|
7
|
+
if (!obj || typeof obj !== 'object') return false;
|
|
8
|
+
const module = obj as Record<string | symbol, any>;
|
|
9
|
+
return (
|
|
10
|
+
Boolean(module.__esModule) || module[Symbol.toStringTag] === 'Module'
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolve a component from potentially wrapped module format.
|
|
16
|
+
* Handles ES modules with default exports.
|
|
17
|
+
* @param component - The component to resolve
|
|
18
|
+
* @returns The resolved component
|
|
19
|
+
*/
|
|
20
|
+
export function resolveComponent(component: unknown): unknown {
|
|
21
|
+
if (!component) return null;
|
|
22
|
+
|
|
23
|
+
if (isESModule(component)) {
|
|
24
|
+
return component.default || component;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (
|
|
28
|
+
component &&
|
|
29
|
+
typeof component === 'object' &&
|
|
30
|
+
!Array.isArray(component) &&
|
|
31
|
+
'default' in component &&
|
|
32
|
+
Object.keys(component).length === 1
|
|
33
|
+
) {
|
|
34
|
+
return (component as { default: unknown }).default;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return component;
|
|
38
|
+
}
|