crossroad 2.0.0-alpha.1 → 2.0.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}"`);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};
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 u=()=>"undefined"==typeof window;function p(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=p(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=>p(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 m=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]})(),w=()=>{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:p})=>{const c=a||(u()?"/":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(u())return;const e=()=>f(s(window.location.href)),t=e=>{const t=(e=>{if(!e)return null;const t=e.getAttribute("href");if(!t)return null;const r=t.trim();return/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(r)||null!==e.getAttribute("target")?null:r})(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]},p)};export{i as Context,h as Route,f as Switch,w as useHash,q as useParams,y as usePath,m as useQuery,c as useUrl};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crossroad",
3
- "version": "2.0.0-alpha.1",
3
+ "version": "2.0.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",
package/readme.md CHANGED
@@ -240,7 +240,7 @@ const UserList = () => <div>List here</div>;
240
240
  <Route path="/user/:id" render={({ id }) => <User id={id} />} />;
241
241
  // <div>Hello 25 (string)</div>
242
242
 
243
- <Route path="/user/:id<umber>" render={({ id }) => <User id={id} />} />;
243
+ <Route path="/user/:id<number>" render={({ id }) => <User id={id} />} />;
244
244
  // <div>Hello 25 (number)</div>
245
245
 
246
246
  // Avoid when you need the params, since they cannot be passed easily
@@ -286,6 +286,18 @@ It can also match query parameters:
286
286
  <Route path="/profile?page=options" component={User} /> // Wrong value
287
287
  ```
288
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
+
289
301
  ### `<a>`
290
302
 
291
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:
@@ -305,6 +317,7 @@ An important concept to understand is where links open, whether it's a react nav
305
317
  - `/`: plain paths will navigate within React
306
318
  - `/?abc=def`: queries, hashtags, etc. will also perform a navigation in React
307
319
  - `https://example.com/`: full URLs will trigger a browser page change
320
+ - `tel:+1234567890`, `mailto:test@example.com`: non-HTTP protocol links will trigger a browser action (call, email, etc.)
308
321
  - `target="_self"`: will trigger a browser page change, in the same tab
309
322
  - `target="_blank"`: will open a new tab
310
323
 
@@ -651,24 +664,20 @@ function Profile({ id }: { id: number }) {
651
664
  <Route path="/users/:id<number>" component={Profile} />
652
665
  ````
653
666
 
654
- Because with React Context we cannot infer the types properly, we strongly recommend to add the types when using useParams:
667
+ Because with React Context we cannot infer the types properly, so if you want to differentiate between string | number you can do so with:
655
668
 
656
669
  ```js
657
670
  // <Route path="/users/:id/books/:bookId" ... />
658
- const userId = useParams<string>("id");
659
- // "25"
660
- const bookId = useParams<string>("bookId");
661
- // "55"
671
+ const userId = useParams<string>("id"); // "25"
672
+ const bookId = useParams<string>("bookId"); // "55"
662
673
 
663
674
 
664
675
  // <Route path="/users/:id<number>/books/:bookId<number>" ... />
665
- const userId = useParams<number>("id");
666
- // 25
667
- const bookId = useParams<number>("bookId");
668
- // 55
676
+ const userId = useParams<number>("id"); // 25
677
+ const bookId = useParams<number>("bookId"); // 55
669
678
  ```
670
679
 
671
- You can also type the whole list of params, but we strongly recommend doing it as shown previously:
680
+ You can also type the whole list of params, but we recommend using useParams() with the key argument:
672
681
 
673
682
  ```js
674
683
  // <Route path="/users/:id/books/:bookId" ... />
@@ -807,6 +816,46 @@ export default function SearchForm() {
807
816
 
808
817
  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.
809
818
 
819
+ ### Dynamic Pagination
820
+
821
+ 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:
822
+
823
+ ```tsx
824
+ import Router, { Switch, Route, useQuery, useParams } from "crossroad";
825
+
826
+ const Category = ({ category, page }) => {
827
+ const posts = fetchPosts(category, page); // Simulated API call
828
+
829
+ return (
830
+ <div>
831
+ <h1>{category} Posts - Page {page}</h1>
832
+ <ul>{posts.map(post => <li key={post.id}>{post.title}</li>)}</ul>
833
+ <nav>
834
+ {page > 1 ? (
835
+ <a href={`/${category}/${page - 1}`}>Previous</a>
836
+ ) : 'Previous'}
837
+ <a href={`/${category}/${page + 1}`}>Next</a>
838
+ </nav>
839
+ </div>
840
+ );
841
+ };
842
+
843
+ export default function App() {
844
+ return (
845
+ <Router>
846
+ <nav>
847
+ <a href="/tech/1">Tech</a>
848
+ <a href="/lifestyle/1">Lifestyle</a>
849
+ </nav>
850
+ <Switch>
851
+ <Route path="/:category/:page<number>" component={Category} />
852
+ <Route path="/" component={() => <div>Home</div>} />
853
+ </Switch>
854
+ </Router>
855
+ );
856
+ }
857
+ ```
858
+
810
859
  ### Query routing
811
860
 
812
861
  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
@@ -6,9 +6,11 @@ type ParseParamType<T extends string> = T extends `${infer Name}<${infer Type}>`
6
6
  ? { [K in Name]: number }
7
7
  : Type extends "string"
8
8
  ? { [K in Name]: string }
9
- : Type extends "boolean"
10
- ? { [K in Name]: boolean }
11
- : { [K in Name]: string } // default to 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
12
14
  : T extends `${infer Name}`
13
15
  ? { [K in Name]: string }
14
16
  : never;
@@ -22,8 +24,9 @@ type ExtractParams<T extends string> =
22
24
  : {};
23
25
 
24
26
  // Main Route component with type inference
25
- interface RouteProps<T extends Record<string, string | number | boolean> = {}>
26
- extends Omit<
27
+ interface RouteProps<
28
+ T extends Record<string, string | number | boolean | Date> = {},
29
+ > extends Omit<
27
30
  {
28
31
  path?: string;
29
32
  scrollUp?: boolean;
@@ -73,7 +76,7 @@ type UrlSet = {
73
76
  query?: Query;
74
77
  hash?: string;
75
78
  };
76
- type Params = Record<string, string | number>;
79
+ type Params = Record<string, string | number | boolean | Date>;
77
80
  type Callable<T = string> = React.Dispatch<React.SetStateAction<T>>;
78
81
  declare const Context: React.Context<[Url, Callable<UrlSet | string>]>;
79
82
  declare function useUrl(): [Url, Callable<UrlSet | string>];
@@ -82,7 +85,9 @@ declare function useQuery(): [Query, Callable<Query>];
82
85
  declare function useQuery(key: string): [string, Callable<string>];
83
86
  declare function useHash(): [string, Callable<string>];
84
87
  declare function useParams<T = Params>(): T;
85
- declare function useParams<T = string | number>(key: string): T;
88
+ declare function useParams<T = string | number | boolean | Date>(
89
+ key: string,
90
+ ): T;
86
91
 
87
92
  export default Router;
88
93
  export {