crossroad 1.3.3 → 2.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/index.min.js +1 -1
- package/package.json +3 -2
- package/readme.md +105 -17
- package/src/index.d.ts +66 -20
package/index.min.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import
|
|
1
|
+
import e,{createContext as t,useState as r,useCallback as n,useEffect as o,useContext as a}from"react";var i=t();const s=e=>{if("string"!=typeof e)return e;const t={},r=new URL(e,"http://localhost:3000/");t.path=(r.pathname.replace(/\/$/,"")||"/").replaceAll("%3C","<").replaceAll("%3E",">"),t.query={};for(const[e]of r.searchParams)t.query[e]=(n=r.searchParams.getAll(e)).length>1?n:n[0];var n;return r.hash&&(t.hash=r.hash.replace(/^#/,"")),t},l=e=>{if("string"==typeof e)return e;const{path:t,query:r={},hash:n}=e||{};let o=t||"/";const a=new URLSearchParams(Object.entries(r).map(([e,t])=>(Array.isArray(t)?t:[t]).map(t=>[e,t])).flat().filter(([e,t])=>t)).toString();return a&&(o+="?"+a),n&&(o+="#"+n),o};var p=()=>"undefined"==typeof window;function u(e,t,r={}){if(e=JSON.parse(JSON.stringify(s(e))),(t=JSON.parse(JSON.stringify(s(t)))).path=t.path.replace(/\/$/,"")||"/",e.path=e.path.replace(/\/$/,"")||"/",e.path.endsWith("*")){e.path=e.path.replace(/\/?\*/,"")||"/";const r=e.path.split("/").filter(Boolean).length;t.path="/"+t.path.slice(1).split("/").slice(0,r).join("/")}if(Object.entries(e.query).length)for(let r in e.query){if(!(r in t.query))return!1;if(e.query[r]&&e.query[r]!==t.query[r])return!1}if(!e.path.includes(":"))return e.path===t.path&&r;if(e.path.split("/").length!==t.path.split("/").length)return!1;const n={},o=e.path.split("/").every((e,o)=>{const a=t.path.split("/")[o];if(e.startsWith(":")){let t=e.slice(1),o="string";t.includes("<")&&([t,o]=t.split("<"),o=o.slice(0,-1));const i=decodeURIComponent(a);return n[t]="number"===o?Number(i):"date"===o?new Date(/^\d+$/.test(i)?Number(i):i):"boolean"===o?"true"===i:i,r}return a===e});return o&&Object.assign(r,n),o&&r}var c=()=>{const e=a(i);if(!e)throw new Error("Wrap your App with <Router>");return e},h=({path:t="*",scrollUp:r,component:n,render:o,children:a})=>{const s=c(),l=u(t,s[0]);if(!l)return null;if(r&&window.scrollTo(0,0),n){const t=n;a=e.createElement(t,l)}else if(o)a=o(l);else if(!a)throw new Error("Route needs prop `component`, `render` or `children`");return e.createElement(i.Provider,{value:[{...s[0],params:l},...s.slice(1)]},a)};var f=({redirect:e,children:t})=>{const[r,n]=c(),a=(e=>(Array.isArray(e)||(e=[e]),e.filter(e=>e&&e.props)))(t).find(e=>u(e.props.path||"*",r))||null;return o(()=>{e&&(a||("function"==typeof e&&(e=e(r)),n(l(e))))},[e,a]),a},y=()=>{const[e,t]=c(),r=n((e,r)=>{t(t=>("function"==typeof e&&(e=e(t.path)),"string"!=typeof e&&(e="/"),{...t,path:e}),r)},[]);return[e.path,r]};const d=e=>l({query:e});var w=e=>e?(e=>{const[t,r]=c(),o=n((t,n)=>{r(r=>{const n=r.query[e];if((t="function"==typeof t?t(n):t)===n)return r;if(t)return{...r,query:{...r.query,[e]:t}};{const{[e]:t,...n}=r.query;return{...r,query:n}}},n)},[]);return[t.query[e],o]})(e):(()=>{const[e,t]=c(),r=n((e,r)=>{t(t=>("string"==typeof(e="function"==typeof e?e(t.query):e)&&(e=s("/?"+e.replace(/^\?/,"")).query),e=s(d(e)).query,d(e)===d(t.query)?t:{...t,query:e}),r)},[]);return[e.query,r]})(),m=()=>{const[e,t]=c(),r=n((e,r)=>{t(t=>("function"==typeof e&&(e=e(t.hash)),"string"!=typeof e&&(e=""),e=e.replace(/^#/,""),{...t,hash:e}),r)},[]);return[e.hash,r]},q=e=>{const[{params:t}]=a(i);return e?e in t?t[e]:"":t||{}};export default({scrollUp:t,url:a,children:u})=>{const c=a||(p()?"/":window.location.href),[h,f]=r(()=>s(c)),y=n((e,{mode:r="push"}={})=>{if(!history[r+"State"])throw new Error(`Invalid mode "${r}"`);f(n=>(e="function"==typeof e?e(n):e,l(n)===l(e)?n:(history[r+"State"]({},null,l(e)),t&&window.scrollTo(0,0),s(e))))},[]);return o(()=>{if(p())return;const e=()=>f(s(window.location.href)),t=e=>{const t=(e=>{if(!e)return null;const t=e.getAttribute("href");return t?/^https?:\/\//.test(t)||null!==e.getAttribute("target")?null:t:null})(e.target.closest("a"));if(!t)return!1;e.preventDefault();const[r,n]=t.split("#");r&&y(r),n&&(window.location.hash="#"+n)};return window.addEventListener("popstate",e),document.addEventListener("click",t),()=>{window.removeEventListener("popstate",e),document.removeEventListener("click",t)}},[y]),e.createElement(i.Provider,{value:[h,y]},u)};export{i as Context,h as Route,f as Switch,m as useHash,q as useParams,y as usePath,w as useQuery,c as useUrl};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crossroad",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "A React library to handle navigation easily in your WebApp",
|
|
5
5
|
"homepage": "https://crossroad.page/",
|
|
6
6
|
"repository": "https://github.com/franciscop/crossroad.git",
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
"jest": "^29.7.0",
|
|
39
39
|
"jest-environment-jsdom": "^29.7.0",
|
|
40
40
|
"react": "^18.3.1",
|
|
41
|
-
"react-
|
|
41
|
+
"react-dom": "^18.3.1",
|
|
42
|
+
"react-test": "^0.22.1",
|
|
42
43
|
"rollup": "^1.32.1",
|
|
43
44
|
"rollup-plugin-babel": "^4.4.0",
|
|
44
45
|
"rollup-plugin-terser": "^5.2.0"
|
package/readme.md
CHANGED
|
@@ -26,7 +26,7 @@ export default function App() {
|
|
|
26
26
|
<Switch redirect="/">
|
|
27
27
|
<Route path="/" component={Home} />
|
|
28
28
|
<Route path="/users" component={Users} />
|
|
29
|
-
<Route path="/users/:id" component={Profile} />
|
|
29
|
+
<Route path="/users/:id<number>" component={Profile} />
|
|
30
30
|
</Switch>
|
|
31
31
|
</Router>
|
|
32
32
|
);
|
|
@@ -200,12 +200,14 @@ The `<Switch>` component only accepts `<Route>` as its children.
|
|
|
200
200
|
|
|
201
201
|
This component defines a conditional path that, when strictly matched, renders the given component. Its props are:
|
|
202
202
|
|
|
203
|
-
- `path`: the path to match to the current browser's URL. It can have parameters `/:id
|
|
203
|
+
- `path`: the path to match to the current browser's URL. It can have parameters `/:id`, with optional types like `/:id<number>`, and a wildcard at the end `*` to make it a partial route.
|
|
204
204
|
- `component`: the component that will be rendered if the browser's URL matches the `path` parameter.
|
|
205
205
|
- `render`: a function that will be called with the params if the browser's URL matches the `path` parameter.
|
|
206
206
|
- `children`: the children to render if the browser's URL matches the `path` parameter.
|
|
207
207
|
- `scrollUp`: automatically scroll up the browser window when this route/component/etc is matched.
|
|
208
208
|
|
|
209
|
+
> Exactly one of "component | render | children" props must be defined, not 0, not multiple.
|
|
210
|
+
|
|
209
211
|
So for example if the `path` prop is `"/user"` and you visit the page `"/user"`, then the component is rendered; it is ignored otherwise:
|
|
210
212
|
|
|
211
213
|
```js
|
|
@@ -225,17 +227,23 @@ So for example if the `path` prop is `"/user"` and you visit the page `"/user"`,
|
|
|
225
227
|
When matching a path with a parameter (a part of the url that starts with `:`) it will be passed as a prop straight to the children:
|
|
226
228
|
|
|
227
229
|
```js
|
|
228
|
-
// In https://example.com/user/
|
|
229
|
-
const User = ({ id }) => <div>Hello {id}</div>;
|
|
230
|
+
// In https://example.com/user/25
|
|
231
|
+
const User = ({ id }) => <div>Hello {id} ({typeof id})</div>;
|
|
230
232
|
const UserList = () => <div>List here</div>;
|
|
231
233
|
|
|
232
234
|
<Route path="/user/:id" component={User} />;
|
|
233
|
-
// <div>Hello
|
|
235
|
+
// <div>Hello 25 (string)</div>
|
|
236
|
+
|
|
237
|
+
<Route path="/user/:id<number>" component={User} />;
|
|
238
|
+
// <div>Hello 25 (number)</div>
|
|
234
239
|
|
|
235
240
|
<Route path="/user/:id" render={({ id }) => <User id={id} />} />;
|
|
236
|
-
// <div>Hello
|
|
241
|
+
// <div>Hello 25 (string)</div>
|
|
242
|
+
|
|
243
|
+
<Route path="/user/:id<number>" render={({ id }) => <User id={id} />} />;
|
|
244
|
+
// <div>Hello 25 (number)</div>
|
|
237
245
|
|
|
238
|
-
// Avoid when you need the params, since they cannot be passed
|
|
246
|
+
// Avoid when you need the params, since they cannot be passed easily
|
|
239
247
|
<Route path="/user/">
|
|
240
248
|
<UserList />
|
|
241
249
|
</Route>;
|
|
@@ -278,6 +286,18 @@ It can also match query parameters:
|
|
|
278
286
|
<Route path="/profile?page=options" component={User} /> // Wrong value
|
|
279
287
|
```
|
|
280
288
|
|
|
289
|
+
Finally, it can also attempt to convert the types to the specified format. It does _not_ run validation. The only really useful type right now is `number` and `date` (`string` is the default so no need for it):
|
|
290
|
+
|
|
291
|
+
```jsx
|
|
292
|
+
<Route path="/user/:id<number>" render={({ id }) => {
|
|
293
|
+
console.log(id, typeof id); // 25 number
|
|
294
|
+
}} />;
|
|
295
|
+
|
|
296
|
+
<Route path="/metrics/:time<date>" render={({ time }) => {
|
|
297
|
+
console.log(time, typeof time); // Jan 25th, 2055 Date
|
|
298
|
+
}} />;
|
|
299
|
+
````
|
|
300
|
+
|
|
281
301
|
### `<a>`
|
|
282
302
|
|
|
283
303
|
Links with Crossroad are just traditional plain `<a>`. You write the URL and a relative path, and Crossroad handles all the history, routing, etc:
|
|
@@ -622,24 +642,52 @@ setHash("newhash", { mode: "replace" });
|
|
|
622
642
|
|
|
623
643
|
### `useParams()`
|
|
624
644
|
|
|
625
|
-
|
|
645
|
+
Get the parameters from the matched URL, already parsed as an object, or pass an argument to just get the key:
|
|
626
646
|
|
|
627
|
-
```
|
|
628
|
-
|
|
629
|
-
const
|
|
630
|
-
//
|
|
647
|
+
```ts
|
|
648
|
+
function Profile() {
|
|
649
|
+
const { username } = useParams();
|
|
650
|
+
// or
|
|
651
|
+
const username = useParams('username');
|
|
652
|
+
return <div>Hello {username}</div>;
|
|
653
|
+
}
|
|
631
654
|
```
|
|
632
655
|
|
|
633
|
-
|
|
656
|
+
The path in the [Route](#route) can also specify the type. Whenever possible it's preferable to use the props (since those can be type-checked automatically):
|
|
657
|
+
|
|
658
|
+
```js
|
|
659
|
+
function Profile({ id }: { id: number }) {
|
|
660
|
+
return <div>Hello {id}</div>;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
<Route path="/users/:id<number>" component={Profile} />
|
|
664
|
+
````
|
|
634
665
|
|
|
635
|
-
|
|
666
|
+
Because with React Context we cannot infer the types properly, so if you want to differentiate between string | number you can do so with:
|
|
636
667
|
|
|
637
668
|
```js
|
|
638
|
-
//
|
|
639
|
-
const
|
|
640
|
-
//
|
|
669
|
+
// <Route path="/users/:id/books/:bookId" ... />
|
|
670
|
+
const userId = useParams<string>("id"); // "25"
|
|
671
|
+
const bookId = useParams<string>("bookId"); // "55"
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
// <Route path="/users/:id<number>/books/:bookId<number>" ... />
|
|
675
|
+
const userId = useParams<number>("id"); // 25
|
|
676
|
+
const bookId = useParams<number>("bookId"); // 55
|
|
641
677
|
```
|
|
642
678
|
|
|
679
|
+
You can also type the whole list of params, but we recommend using useParams() with the key argument:
|
|
680
|
+
|
|
681
|
+
```js
|
|
682
|
+
// <Route path="/users/:id/books/:bookId" ... />
|
|
683
|
+
const params = useParams<{ id: string, bookId: string }>();
|
|
684
|
+
// { id: "25", bookId: "55" }
|
|
685
|
+
|
|
686
|
+
// <Route path="/users/:id<number>/books/:bookId<number>" ... />
|
|
687
|
+
const params = useParams<{ id: number, bookId: number }>();
|
|
688
|
+
// { id: 25, bookId: 55 }
|
|
689
|
+
````
|
|
690
|
+
|
|
643
691
|
## Examples
|
|
644
692
|
|
|
645
693
|
### Static routes
|
|
@@ -767,6 +815,46 @@ export default function SearchForm() {
|
|
|
767
815
|
|
|
768
816
|
In here we can see that we are treating the output of `useQuery` in the same way that we'd treat the output of `useState()`. This is on purpose and it makes things a lot easier for your application to work.
|
|
769
817
|
|
|
818
|
+
### Dynamic Pagination
|
|
819
|
+
|
|
820
|
+
This example shows how to use path params with mixed types to track the current page in the URL, enabling bookmarkable, dynamic list navigation. Clicking "Previous" or "Next" updates the page number:
|
|
821
|
+
|
|
822
|
+
```tsx
|
|
823
|
+
import Router, { Switch, Route, useQuery, useParams } from "crossroad";
|
|
824
|
+
|
|
825
|
+
const Category = ({ category, page }) => {
|
|
826
|
+
const posts = fetchPosts(category, page); // Simulated API call
|
|
827
|
+
|
|
828
|
+
return (
|
|
829
|
+
<div>
|
|
830
|
+
<h1>{category} Posts - Page {page}</h1>
|
|
831
|
+
<ul>{posts.map(post => <li key={post.id}>{post.title}</li>)}</ul>
|
|
832
|
+
<nav>
|
|
833
|
+
{page > 1 ? (
|
|
834
|
+
<a href={`/${category}/${page - 1}`}>Previous</a>
|
|
835
|
+
) : 'Previous'}
|
|
836
|
+
<a href={`/${category}/${page + 1}`}>Next</a>
|
|
837
|
+
</nav>
|
|
838
|
+
</div>
|
|
839
|
+
);
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
export default function App() {
|
|
843
|
+
return (
|
|
844
|
+
<Router>
|
|
845
|
+
<nav>
|
|
846
|
+
<a href="/tech/1">Tech</a>
|
|
847
|
+
<a href="/lifestyle/1">Lifestyle</a>
|
|
848
|
+
</nav>
|
|
849
|
+
<Switch>
|
|
850
|
+
<Route path="/:category/:page<number>" component={Category} />
|
|
851
|
+
<Route path="/" component={() => <div>Home</div>} />
|
|
852
|
+
</Switch>
|
|
853
|
+
</Router>
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
```
|
|
857
|
+
|
|
770
858
|
### Query routing
|
|
771
859
|
|
|
772
860
|
Some times you prefer the current page to be defined by the query, instead of by the pathname. This might be true for subpages, for tabs, or for other things depending on your app. With Crossroad it's easy to manage:
|
package/src/index.d.ts
CHANGED
|
@@ -1,47 +1,93 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
|
|
3
|
-
type
|
|
3
|
+
// Helper type to parse parameter types from path string
|
|
4
|
+
type ParseParamType<T extends string> = T extends `${infer Name}<${infer Type}>`
|
|
5
|
+
? Type extends "number"
|
|
6
|
+
? { [K in Name]: number }
|
|
7
|
+
: Type extends "string"
|
|
8
|
+
? { [K in Name]: string }
|
|
9
|
+
: Type extends "date"
|
|
10
|
+
? { [K in Name]: Date }
|
|
11
|
+
: Type extends "boolean"
|
|
12
|
+
? { [K in Name]: boolean }
|
|
13
|
+
: { [K in Name]: string } // default to string
|
|
14
|
+
: T extends `${infer Name}`
|
|
15
|
+
? { [K in Name]: string }
|
|
16
|
+
: never;
|
|
4
17
|
|
|
5
|
-
|
|
18
|
+
// Extracts all params from path and combines them
|
|
19
|
+
type ExtractParams<T extends string> =
|
|
20
|
+
T extends `${infer Prefix}/:${infer Param}/${infer Rest}`
|
|
21
|
+
? ParseParamType<Param> & ExtractParams<`/${Rest}`>
|
|
22
|
+
: T extends `${infer Prefix}/:${infer Param}`
|
|
23
|
+
? ParseParamType<Param>
|
|
24
|
+
: {};
|
|
6
25
|
|
|
7
|
-
|
|
26
|
+
// Main Route component with type inference
|
|
27
|
+
interface RouteProps<
|
|
28
|
+
T extends Record<string, string | number | boolean | Date> = {},
|
|
29
|
+
> extends Omit<
|
|
30
|
+
{
|
|
31
|
+
path?: string;
|
|
32
|
+
scrollUp?: boolean;
|
|
33
|
+
component?: React.FunctionComponent<T>;
|
|
34
|
+
render?: (params: T) => React.ReactNode;
|
|
35
|
+
children?: React.ReactNode;
|
|
36
|
+
},
|
|
37
|
+
"component" | "render"
|
|
38
|
+
> {
|
|
8
39
|
path?: string;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Infer params from path string literal
|
|
43
|
+
type InferParamsFromPath<T extends string> = T extends `${string}/:${string}`
|
|
44
|
+
? ExtractParams<T>
|
|
45
|
+
: {};
|
|
46
|
+
|
|
47
|
+
// Modified Route declaration with path-based inference
|
|
48
|
+
declare const Route: <P extends string = string>(
|
|
49
|
+
props: RouteProps<InferParamsFromPath<P>> & { path?: P } & (
|
|
50
|
+
| {
|
|
51
|
+
component: React.FunctionComponent<InferParamsFromPath<P>>;
|
|
52
|
+
render?: never;
|
|
53
|
+
}
|
|
54
|
+
| {
|
|
55
|
+
render: (params: InferParamsFromPath<P>) => React.ReactNode;
|
|
56
|
+
component?: never;
|
|
57
|
+
}
|
|
58
|
+
| {}
|
|
59
|
+
),
|
|
60
|
+
) => React.ReactNode;
|
|
14
61
|
|
|
62
|
+
// Rest of your original declarations remain the same
|
|
63
|
+
type FC<T = {}> = React.FC<React.PropsWithChildren<T>>;
|
|
64
|
+
declare const Router: FC<{ scrollUp?: boolean; url?: string }>;
|
|
15
65
|
declare const Switch: FC<{
|
|
16
66
|
redirect?: string | { path: string } | (() => string);
|
|
17
67
|
}>;
|
|
18
|
-
|
|
19
|
-
type Query = {
|
|
20
|
-
[key: string]: string;
|
|
21
|
-
};
|
|
22
|
-
|
|
68
|
+
type Query = Record<string, string>;
|
|
23
69
|
type Url = URL & {
|
|
24
70
|
path: string;
|
|
25
71
|
query: Query;
|
|
26
72
|
hash?: string;
|
|
27
73
|
};
|
|
28
|
-
|
|
29
74
|
type UrlSet = {
|
|
30
75
|
path?: string;
|
|
31
76
|
query?: Query;
|
|
32
77
|
hash?: string;
|
|
33
78
|
};
|
|
34
|
-
|
|
35
|
-
declare const Context: React.Context<any>;
|
|
36
|
-
|
|
79
|
+
type Params = Record<string, string | number | boolean | Date>;
|
|
37
80
|
type Callable<T = string> = React.Dispatch<React.SetStateAction<T>>;
|
|
38
|
-
|
|
81
|
+
declare const Context: React.Context<[Url, Callable<UrlSet | string>]>;
|
|
39
82
|
declare function useUrl(): [Url, Callable<UrlSet | string>];
|
|
40
83
|
declare function usePath(): [string, Callable<string>];
|
|
41
84
|
declare function useQuery(): [Query, Callable<Query>];
|
|
42
|
-
declare function useQuery(
|
|
85
|
+
declare function useQuery(key: string): [string, Callable<string>];
|
|
43
86
|
declare function useHash(): [string, Callable<string>];
|
|
44
|
-
declare function useParams(
|
|
87
|
+
declare function useParams<T = Params>(): T;
|
|
88
|
+
declare function useParams<T = string | number | boolean | Date>(
|
|
89
|
+
key: string,
|
|
90
|
+
): T;
|
|
45
91
|
|
|
46
92
|
export default Router;
|
|
47
93
|
export {
|