crossroad 1.3.2 → 2.0.0-alpha.1

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 CHANGED
@@ -1 +1 @@
1
- import t,{createContext as e,useState as r,useCallback as n,useEffect as o,useContext as a}from"react";var i=e();const s=t=>{if("string"!=typeof t)return t;const e={},r=new URL(t,"http://localhost:3000/");e.path=r.pathname.replace(/\/$/,"")||"/",e.query={};for(const[t]of r.searchParams)e.query[t]=(n=r.searchParams.getAll(t)).length>1?n:n[0];var n;return r.hash&&(e.hash=r.hash.replace(/^#/,"")),e},p=t=>{if("string"==typeof t)return t;const{path:e,query:r={},hash:n}=t||{};let o=e||"/";const a=new URLSearchParams(Object.entries(r).map(([t,e])=>(Array.isArray(e)?e:[e]).map(e=>[t,e])).flat().filter(([t,e])=>e)).toString();return a&&(o+="?"+a),n&&(o+="#"+n),o};var u=()=>"undefined"==typeof window;function l(t,e,r={}){if(t=JSON.parse(JSON.stringify(s(t))),(e=JSON.parse(JSON.stringify(s(e)))).path=e.path.replace(/\/$/,"")||"/",t.path=t.path.replace(/\/$/,"")||"/",t.path.endsWith("*")){t.path=t.path.replace(/\/?\*/,"")||"/";const r=t.path.split("/").filter(Boolean).length;e.path="/"+e.path.slice(1).split("/").slice(0,r).join("/")}if(Object.entries(t.query).length)for(let r in t.query){if(!(r in e.query))return!1;if(t.query[r]&&t.query[r]!==e.query[r])return!1}if(!t.path.includes(":"))return t.path===e.path&&r;if(t.path.split("/").length!==e.path.split("/").length)return!1;const n={},o=t.path.split("/").every((t,o)=>{const a=e.path.split("/")[o];return t.startsWith(":")?(n[t.slice(1)]=decodeURIComponent(a),r):a===t});return o&&Object.assign(r,n),o&&r}var c=({path:e="*",scrollUp:r,component:n,render:o,children:s})=>{const p=a(i),u=l(e,p[0]);if(!u)return null;if(r&&window.scrollTo(0,0),n){const e=n;s=t.createElement(e,u)}else if(o)s=o(u);else if(!s)throw new Error("Route needs prop `component`, `render` or `children`");return t.createElement(i.Provider,{value:[{...p[0],params:u},...p.slice(1)]},s)},h=()=>{const t=a(i);if(!t)throw new Error("Wrap your App with <Router>");return t};var f=({redirect:t,children:e})=>{const[r,n]=h(),a=(t=>(Array.isArray(t)||(t=[t]),t.filter(t=>t&&t.props)))(e).find(t=>l(t.props.path||"*",r))||null;return o(()=>{t&&(a||("function"==typeof t&&(t=t(r)),n(p(t))))},[t,a]),a},y=()=>{const[t,e]=h(),r=n((t,r)=>{e(e=>("function"==typeof t&&(t=t(e.path)),"string"!=typeof t&&(t="/"),{...e,path:t}),r)},[]);return[t.path,r]};const d=t=>p({query:t});var w=t=>t?(t=>{const[e,r]=h(),o=n((e,n)=>{r(r=>{const n=r.query[t];if((e="function"==typeof e?e(n):e)===n)return r;if(e)return{...r,query:{...r.query,[t]:e}};{const{[t]:e,...n}=r.query;return{...r,query:n}}},n)},[]);return[e.query[t],o]})(t):(()=>{const[t,e]=h(),r=n((t,r)=>{e(e=>("string"==typeof(t="function"==typeof t?t(e.query):t)&&(t=s("/?"+t.replace(/^\?/,"")).query),t=s(d(t)).query,d(t)===d(e.query)?e:{...e,query:t}),r)},[]);return[t.query,r]})(),m=()=>{const[t,e]=h(),r=n((t,r)=>{e(e=>("function"==typeof t&&(t=t(e.hash)),"string"!=typeof t&&(t=""),t=t.replace(/^#/,""),{...e,hash:t}),r)},[]);return[t.hash,r]},q=t=>{const e=a(i);return t?l(t,e[0].path)||{}:e[0].params};export default({scrollUp:e,url:a,children:l})=>{const c=a||(u()?"/":window.location.href),[h,f]=r(()=>s(c)),y=n((t,{mode:r="push"}={})=>{if(!history[r+"State"])throw new Error(`Invalid mode "${r}"`);t="function"==typeof t?t(h):t,f(n=>p(n)===p(t)?n:(history[r+"State"]({},null,p(t)),e&&window.scrollTo(0,0),s(t)))},[]);return o(()=>{if(u())return;const t=()=>f(s(window.location.href)),e=t=>{const e=(t=>{if(!t)return null;const e=t.getAttribute("href");return e?/^https?:\/\//.test(e)||null!==t.getAttribute("target")?null:e:null})(t.target.closest("a"));if(!e)return!1;t.preventDefault();const[r,n]=e.split("#");r&&y(r),n&&(window.location.hash="#"+n)};return window.addEventListener("popstate",t),document.addEventListener("click",e),()=>{window.removeEventListener("popstate",t),document.removeEventListener("click",e)}},[y]),t.createElement(i.Provider,{value:[h,y]},l)};export{i as Context,c as Route,f as Switch,m as useHash,q as useParams,y as usePath,w as useQuery,h as useUrl};
1
+ import t,{createContext as e,useState as r,useCallback as n,useEffect as o,useContext as a}from"react";var i=e();const s=t=>{if("string"!=typeof t)return t;const e={},r=new URL(t,"http://localhost:3000/");e.path=r.pathname.replace(/\/$/,"")||"/",e.query={};for(const[t]of r.searchParams)e.query[t]=(n=r.searchParams.getAll(t)).length>1?n:n[0];var n;return r.hash&&(e.hash=r.hash.replace(/^#/,"")),e},p=t=>{if("string"==typeof t)return t;const{path:e,query:r={},hash:n}=t||{};let o=e||"/";const a=new URLSearchParams(Object.entries(r).map(([t,e])=>(Array.isArray(e)?e:[e]).map(e=>[t,e])).flat().filter(([t,e])=>e)).toString();return a&&(o+="?"+a),n&&(o+="#"+n),o};var u=()=>"undefined"==typeof window;function l(t,e,r={}){if(t=JSON.parse(JSON.stringify(s(t))),(e=JSON.parse(JSON.stringify(s(e)))).path=e.path.replace(/\/$/,"")||"/",t.path=t.path.replace(/\/$/,"")||"/",t.path.endsWith("*")){t.path=t.path.replace(/\/?\*/,"")||"/";const r=t.path.split("/").filter(Boolean).length;e.path="/"+e.path.slice(1).split("/").slice(0,r).join("/")}if(Object.entries(t.query).length)for(let r in t.query){if(!(r in e.query))return!1;if(t.query[r]&&t.query[r]!==e.query[r])return!1}if(!t.path.includes(":"))return t.path===e.path&&r;if(t.path.split("/").length!==e.path.split("/").length)return!1;const n={},o=t.path.split("/").every((t,o)=>{const a=e.path.split("/")[o];return t.startsWith(":")?(n[t.slice(1)]=decodeURIComponent(a),r):a===t});return o&&Object.assign(r,n),o&&r}var c=({path:e="*",scrollUp:r,component:n,render:o,children:s})=>{const p=a(i),u=l(e,p[0]);if(!u)return null;if(r&&window.scrollTo(0,0),n){const e=n;s=t.createElement(e,u)}else if(o)s=o(u);else if(!s)throw new Error("Route needs prop `component`, `render` or `children`");return t.createElement(i.Provider,{value:[{...p[0],params:u},...p.slice(1)]},s)},h=()=>{const t=a(i);if(!t)throw new Error("Wrap your App with <Router>");return t};var f=({redirect:t,children:e})=>{const[r,n]=h(),a=(t=>(Array.isArray(t)||(t=[t]),t.filter(t=>t&&t.props)))(e).find(t=>l(t.props.path||"*",r))||null;return o(()=>{t&&(a||("function"==typeof t&&(t=t(r)),n(p(t))))},[t,a]),a},y=()=>{const[t,e]=h(),r=n((t,r)=>{e(e=>("function"==typeof t&&(t=t(e.path)),"string"!=typeof t&&(t="/"),{...e,path:t}),r)},[]);return[t.path,r]};const d=t=>p({query:t});var w=t=>t?(t=>{const[e,r]=h(),o=n((e,n)=>{r(r=>{const n=r.query[t];if((e="function"==typeof e?e(n):e)===n)return r;if(e)return{...r,query:{...r.query,[t]:e}};{const{[t]:e,...n}=r.query;return{...r,query:n}}},n)},[]);return[e.query[t],o]})(t):(()=>{const[t,e]=h(),r=n((t,r)=>{e(e=>("string"==typeof(t="function"==typeof t?t(e.query):t)&&(t=s("/?"+t.replace(/^\?/,"")).query),t=s(d(t)).query,d(t)===d(e.query)?e:{...e,query:t}),r)},[]);return[t.query,r]})(),m=()=>{const[t,e]=h(),r=n((t,r)=>{e(e=>("function"==typeof t&&(t=t(e.hash)),"string"!=typeof t&&(t=""),t=t.replace(/^#/,""),{...e,hash:t}),r)},[]);return[t.hash,r]},q=t=>{const e=a(i);return t?l(t,e[0].path)||{}:e[0].params};export default({scrollUp:e,url:a,children:l})=>{const c=a||(u()?"/":window.location.href),[h,f]=r(()=>s(c)),y=n((t,{mode:r="push"}={})=>{if(!history[r+"State"])throw new Error(`Invalid mode "${r}"`);f(n=>(t="function"==typeof t?t(n):t,p(n)===p(t)?n:(history[r+"State"]({},null,p(t)),e&&window.scrollTo(0,0),s(t))))},[]);return o(()=>{if(u())return;const t=()=>f(s(window.location.href)),e=t=>{const e=(t=>{if(!t)return null;const e=t.getAttribute("href");return e?/^https?:\/\//.test(e)||null!==t.getAttribute("target")?null:e:null})(t.target.closest("a"));if(!e)return!1;t.preventDefault();const[r,n]=e.split("#");r&&y(r),n&&(window.location.hash="#"+n)};return window.addEventListener("popstate",t),document.addEventListener("click",e),()=>{window.removeEventListener("popstate",t),document.removeEventListener("click",e)}},[y]),t.createElement(i.Provider,{value:[h,y]},l)};export{i as Context,c as Route,f as Switch,m as useHash,q as useParams,y as usePath,w as useQuery,h as useUrl};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crossroad",
3
- "version": "1.3.2",
3
+ "version": "2.0.0-alpha.1",
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-test": "^0.21.3",
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` and a wildcard at the end `*` to make it a partial route.
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/abc
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 abc</div>
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 abc</div>
241
+ // <div>Hello 25 (string)</div>
242
+
243
+ <Route path="/user/:id<umber>" 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>;
@@ -622,24 +630,56 @@ setHash("newhash", { mode: "replace" });
622
630
 
623
631
  ### `useParams()`
624
632
 
625
- Parse the current URL against the given reference:
633
+ Get the parameters from the matched URL, already parsed as an object, or pass an argument to just get the key:
626
634
 
627
- ```js
628
- // In /users/2
629
- const params = useParams("/users/:id");
630
- // { id: '2' }
635
+ ```ts
636
+ function Profile() {
637
+ const { username } = useParams();
638
+ // or
639
+ const username = useParams('username');
640
+ return <div>Hello {username}</div>;
641
+ }
631
642
  ```
632
643
 
633
- > Note: this returns a plain object, not a [value, setter] array
644
+ 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):
634
645
 
635
- It's not this method responsibility to match the url, just to attempt to parse it, so if there's no good match it'll just return an empty object (use a `<Route />` for path matching):
646
+ ```js
647
+ function Profile({ id }: { id: number }) {
648
+ return <div>Hello {id}</div>;
649
+ }
650
+
651
+ <Route path="/users/:id<number>" component={Profile} />
652
+ ````
653
+
654
+ Because with React Context we cannot infer the types properly, we strongly recommend to add the types when using useParams:
636
655
 
637
656
  ```js
638
- // In /pages/settings
639
- const params = useParams("/users/:id");
640
- // {}
657
+ // <Route path="/users/:id/books/:bookId" ... />
658
+ const userId = useParams<string>("id");
659
+ // "25"
660
+ const bookId = useParams<string>("bookId");
661
+ // "55"
662
+
663
+
664
+ // <Route path="/users/:id<number>/books/:bookId<number>" ... />
665
+ const userId = useParams<number>("id");
666
+ // 25
667
+ const bookId = useParams<number>("bookId");
668
+ // 55
641
669
  ```
642
670
 
671
+ You can also type the whole list of params, but we strongly recommend doing it as shown previously:
672
+
673
+ ```js
674
+ // <Route path="/users/:id/books/:bookId" ... />
675
+ const params = useParams<{ id: string, bookId: string }>();
676
+ // { id: "25", bookId: "55" }
677
+
678
+ // <Route path="/users/:id<number>/books/:bookId<number>" ... />
679
+ const params = useParams<{ id: number, bookId: number }>();
680
+ // { id: 25, bookId: 55 }
681
+ ````
682
+
643
683
  ## Examples
644
684
 
645
685
  ### Static routes
package/src/index.d.ts CHANGED
@@ -1,47 +1,88 @@
1
1
  import React from "react";
2
2
 
3
- type FC<T> = React.FC<React.PropsWithChildren<T>>;
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 "boolean"
10
+ ? { [K in Name]: boolean }
11
+ : { [K in Name]: string } // default to string
12
+ : T extends `${infer Name}`
13
+ ? { [K in Name]: string }
14
+ : never;
4
15
 
5
- declare const Router: FC<{ scrollUp?: boolean; url?: string }>;
16
+ // Extracts all params from path and combines them
17
+ type ExtractParams<T extends string> =
18
+ T extends `${infer Prefix}/:${infer Param}/${infer Rest}`
19
+ ? ParseParamType<Param> & ExtractParams<`/${Rest}`>
20
+ : T extends `${infer Prefix}/:${infer Param}`
21
+ ? ParseParamType<Param>
22
+ : {};
6
23
 
7
- declare const Route: <T = any>(props: {
24
+ // Main Route component with type inference
25
+ interface RouteProps<T extends Record<string, string | number | boolean> = {}>
26
+ extends Omit<
27
+ {
28
+ path?: string;
29
+ scrollUp?: boolean;
30
+ component?: React.FunctionComponent<T>;
31
+ render?: (params: T) => React.ReactNode;
32
+ children?: React.ReactNode;
33
+ },
34
+ "component" | "render"
35
+ > {
8
36
  path?: string;
9
- scrollUp?: boolean;
10
- component?: React.FunctionComponent<T>;
11
- render?: (params: T) => React.ReactNode;
12
- children?: any;
13
- }) => any;
37
+ }
38
+
39
+ // Infer params from path string literal
40
+ type InferParamsFromPath<T extends string> = T extends `${string}/:${string}`
41
+ ? ExtractParams<T>
42
+ : {};
43
+
44
+ // Modified Route declaration with path-based inference
45
+ declare const Route: <P extends string = string>(
46
+ props: RouteProps<InferParamsFromPath<P>> & { path?: P } & (
47
+ | {
48
+ component: React.FunctionComponent<InferParamsFromPath<P>>;
49
+ render?: never;
50
+ }
51
+ | {
52
+ render: (params: InferParamsFromPath<P>) => React.ReactNode;
53
+ component?: never;
54
+ }
55
+ | {}
56
+ ),
57
+ ) => React.ReactNode;
14
58
 
59
+ // Rest of your original declarations remain the same
60
+ type FC<T = {}> = React.FC<React.PropsWithChildren<T>>;
61
+ declare const Router: FC<{ scrollUp?: boolean; url?: string }>;
15
62
  declare const Switch: FC<{
16
63
  redirect?: string | { path: string } | (() => string);
17
64
  }>;
18
-
19
- type Query = {
20
- [key: string]: string;
21
- };
22
-
65
+ type Query = Record<string, string>;
23
66
  type Url = URL & {
24
67
  path: string;
25
68
  query: Query;
26
69
  hash?: string;
27
70
  };
28
-
29
71
  type UrlSet = {
30
72
  path?: string;
31
73
  query?: Query;
32
74
  hash?: string;
33
75
  };
34
-
35
- declare const Context: React.Context<any>;
36
-
76
+ type Params = Record<string, string | number>;
37
77
  type Callable<T = string> = React.Dispatch<React.SetStateAction<T>>;
38
-
78
+ declare const Context: React.Context<[Url, Callable<UrlSet | string>]>;
39
79
  declare function useUrl(): [Url, Callable<UrlSet | string>];
40
80
  declare function usePath(): [string, Callable<string>];
41
81
  declare function useQuery(): [Query, Callable<Query>];
42
- declare function useQuery(filter: string): [string, Callable<string>];
82
+ declare function useQuery(key: string): [string, Callable<string>];
43
83
  declare function useHash(): [string, Callable<string>];
44
- declare function useParams(ref: string): any;
84
+ declare function useParams<T = Params>(): T;
85
+ declare function useParams<T = string | number>(key: string): T;
45
86
 
46
87
  export default Router;
47
88
  export {