diff --git a/docs/docs/misc/wait.mdx b/docs/docs/misc/wait.mdx index 7bddb3d..9124cd3 100644 --- a/docs/docs/misc/wait.mdx +++ b/docs/docs/misc/wait.mdx @@ -23,3 +23,23 @@ wait().then(() => { console.log('Timeout passed'); }); ``` + +### Aborting + +You can pass _abort signal_ from [AbortController](https://developer.mozilla.org/ru/docs/Web/API/AbortController) to reject promise: + +```js +import { wait } from '@krutoo/utils'; + +const controller = new AbortController(); + +wait(1000, { signal: controller.signal }) + .then(() => { + // ... + }) + .catch(reason => { + console.log(reason); // "Fake reason" + }); + +controller.abort('Fake reason'); +``` diff --git a/docs/docs/react/use-location.mdx b/docs/docs/react/use-location.mdx new file mode 100644 index 0000000..ee0cfc4 --- /dev/null +++ b/docs/docs/react/use-location.mdx @@ -0,0 +1,41 @@ +export const meta = { + category: 'React', + title: 'useLocation', +}; + +# `useLocation` + +React hook of state of current location provided by router. + +### Usage + +```tsx +import { useLocation } from '@krutoo/utils/react'; + +function ProfilePage() { + const { pathname, hash, search } = useLocation(); + + return <>{/* ... */}; +} +``` + +### Requirements + +You need to wrap your root component to special `RouterContext` to make router specific hooks working: + +```tsx +import { createRoot } from 'react-dom'; +import { BrowserRouter } from '@krutoo/utils/router'; +import { RouterContext } from '@krutoo/utils/react'; +import { App } from '#components/app'; + +const router = new BrowserRouter(); + +router.connect(); + +createRoot(document.querySelector('#root')).render( + + + , +); +``` diff --git a/docs/docs/react/use-navigate.mdx b/docs/docs/react/use-navigate.mdx new file mode 100644 index 0000000..2deff76 --- /dev/null +++ b/docs/docs/react/use-navigate.mdx @@ -0,0 +1,57 @@ +export const meta = { + category: 'React', + title: 'useNavigate', +}; + +# `useNavigate` + +React hook for navigate between routes of application. + +### Usage + +```tsx +import { useNavigate } from '@krutoo/utils/react'; + +function App() { + const navigate = useNavigate(); + + const handleAvatarClick = () => { + // navigating to specific route + navigate('/profile'); + }; + + const handleBackClick = () => { + // navigating on history + navigate.go(-1); + }; + + return ( +
+ + + {/* ... */} +
+ ); +} +``` + +### Requirements + +You need to wrap your root component to special `RouterContext` to make router specific hooks working: + +```tsx +import { createRoot } from 'react-dom'; +import { BrowserRouter } from '@krutoo/utils/router'; +import { RouterContext } from '@krutoo/utils/react'; +import { App } from '#components/app'; + +const router = new BrowserRouter(); + +router.connect(); + +createRoot(document.querySelector('#root')).render( + + + , +); +``` diff --git a/docs/docs/react/use-route-params.mdx b/docs/docs/react/use-route-params.mdx new file mode 100644 index 0000000..f3d291c --- /dev/null +++ b/docs/docs/react/use-route-params.mdx @@ -0,0 +1,41 @@ +export const meta = { + category: 'React', + title: 'useRouteParams', +}; + +# `useRouteParams` + +React hook to exec _pathname pattern_ and read params from current location. + +### Usage + +```tsx +import { useRouteParams } from '@krutoo/utils/react'; + +function App() { + const { userId } = useRouteParams('/users/:userId'); + + return <>{/* ... */}; +} +``` + +### Requirements + +You need to wrap your root component to special `RouterContext` to make router specific hooks working: + +```tsx +import { createRoot } from 'react-dom'; +import { BrowserRouter } from '@krutoo/utils/router'; +import { RouterContext } from '@krutoo/utils/react'; +import { App } from '#components/app'; + +const router = new BrowserRouter(); + +router.connect(); + +createRoot(document.querySelector('#root')).render( + + + , +); +``` diff --git a/docs/docs/router/browser-router.mdx b/docs/docs/router/browser-router.mdx new file mode 100644 index 0000000..3a82829 --- /dev/null +++ b/docs/docs/router/browser-router.mdx @@ -0,0 +1,158 @@ +import { Callout } from '#components/callout/callout.tsx'; + +export const meta = { + category: 'Router', + title: 'BrowserRouter', +}; + +# Router + +Package provides `BrowserRouter` - basic implementation for controlling _client routing_ in browser. + + + SSR ready + + Can be used in Node.js for Server Side Rendering or Static Site Generation, see next articles. + + + +### Basic usage + +```tsx +import { BrowserRouter } from '@krutoo/utils/router'; + +const router = new BrowserRouter(); + +// connect router to Web APIs +router.connect(); + +// now we can use it to get location info... +console.log(router.getLocation().pathname); + +// ...and for redirects +router.navigate('/profile/settings'); + +// external links also supported +router.navigate('https://google.com'); + +// you can navigate by history (back for example) +router.go(-1); +``` + +### React bindings + +Package provides some hooks and context for working with router in components. + +You need to wrap your root component to special `RouterContext` to make router specific hooks working: + +```tsx +import { createRoot } from 'react-dom'; +import { BrowserRouter } from '@krutoo/utils/router'; +import { RouterContext } from '@krutoo/utils/react'; +import { App } from '#components/app'; + +// we use provided implementation here but you can use your own +const router = new BrowserRouter(); + +// to make it works you need to call connect +router.connect(); + +createRoot(document.querySelector('#root')).render( + + + , +); +``` + +Now you can implement simple routing for example like this: + +```tsx +const ROUTES = [ + { + path: '/', + render: () => , + }, + { + path: '/profile', + render: () => , + }, + { + path: '/items/:itemId', + render: () => , + }, +]; + +function App() { + const { pathname } = useLocation(); + + // find current route + const currentRoute = useMemo(() => { + for (const route of ROUTES) { + const pattern = new URLPattern({ pathname: route.path }); + + if (pattern.test({ pathname })) { + return route; + } + } + }, [pathname]); + + // render current route + <>{currentRoute?.render()}; +} +``` + +And of course you can use other hooks in any of your components: + +```tsx +function ItemPage() { + // current location info + const { pathname, hash, search } = useLocation(); + + // route params + const { groupId, itemId } = useRouteParams('/items/:groupId/:itemId'); + + // navigate function + const navigate = useNavigate(); + + return <>{/* ... */}; +} +``` + +### Using for SSR or SSG + +`BrowserRouter` can be used in Node.js (or other server environment) to implement Server Side Rendering or Static Site Generation. + +You just don't call `connect()` method because under the hood it accesses browser APIs. + +Next example shows how you can implement SSR with React: + +```tsx +import express from 'express'; +import { renderToString } from 'react-dom'; +import { BrowserRouter } from '@krutoo/utils/router'; +import { RouterContext } from '@krutoo/utils/react'; +import { ROUTES } from '#app/routes'; +import { App } from '#components/app'; + +const app = express(); + +for (const route of ROUTES) { + app.get(ROUTES.path, (req, res) => { + const router = new BrowserRouter({ + defaultLocation: { pathname: req.path }, + }); + + const markup = renderToString( + + + , + ); + + res.send(markup); + }); +} + +app.listen(8080, () => { + console.log(`Server running at http://localhost:${8080}`); +}); +``` diff --git a/package.json b/package.json index 5e45f8a..cbf6d85 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,7 @@ "./math": "./dist/math/mod.js", "./misc": "./dist/misc/mod.js", "./react": "./dist/react/mod.js", + "./router": "./dist/router/mod.js", "./rspack": "./dist/rspack/mod.js", "./store": "./dist/store/mod.js", "./testing": "./dist/testing/mod.js", diff --git a/src/react/context/router-context.ts b/src/react/context/router-context.ts new file mode 100644 index 0000000..b3cea43 --- /dev/null +++ b/src/react/context/router-context.ts @@ -0,0 +1,13 @@ +import { createContext, type Context } from 'react'; +import { getStubLocation } from '../../router/utils.ts'; +import type { Router } from '../../router/types.ts'; + +export const RouterContext: Context = createContext({ + getLocation: getStubLocation, + navigate: () => {}, + go: () => {}, + subscribe: () => () => {}, + connect: () => () => {}, +}); + +RouterContext.displayName = 'RouterContext'; diff --git a/src/react/mod.ts b/src/react/mod.ts index a73081b..2097f97 100644 --- a/src/react/mod.ts +++ b/src/react/mod.ts @@ -57,3 +57,9 @@ export * from './portal.tsx'; // IOC export { ContainerContext } from './context/container-context.ts'; export { useDependency } from './use-dependency.ts'; + +// router +export { RouterContext } from './context/router-context.ts'; +export { type UseNavigateReturn, useNavigate } from './router/use-navigate.ts'; +export { useLocation } from './router/use-location.ts'; +export { useRouteParams } from './router/use-route-params.ts'; diff --git a/src/react/router/use-location.ts b/src/react/router/use-location.ts new file mode 100644 index 0000000..eb878df --- /dev/null +++ b/src/react/router/use-location.ts @@ -0,0 +1,25 @@ +import { useContext, useState } from 'react'; +import { RouterContext } from '../context/router-context.ts'; +import type { RouterLocation } from '../../router/types.ts'; +import { useIsomorphicLayoutEffect } from '../use-isomorphic-layout-effect.ts'; + +/** + * Returns current router location state. + * @returns Location. + */ +export function useLocation(): RouterLocation { + const router = useContext(RouterContext); + const [location, setLocation] = useState(() => router.getLocation()); + + useIsomorphicLayoutEffect(() => { + const sync = () => { + setLocation(router.getLocation()); + }; + + sync(); + + return router.subscribe(sync); + }, [router]); + + return location; +} diff --git a/src/react/router/use-navigate.ts b/src/react/router/use-navigate.ts new file mode 100644 index 0000000..140665f --- /dev/null +++ b/src/react/router/use-navigate.ts @@ -0,0 +1,24 @@ +import { useContext, useMemo } from 'react'; +import type { Router } from '../../router/types.ts'; +import { RouterContext } from '../context/router-context.ts'; + +export type UseNavigateReturn = Router['navigate'] & { + go: Router['go']; +}; + +/** + * Returns navigate function with extra methods. + * @returns Navigate function. + */ +export function useNavigate(): UseNavigateReturn { + const router = useContext(RouterContext); + + return useMemo(() => { + const navigateWrapper: UseNavigateReturn = (url: string) => router.navigate(url); + + // eslint-disable-next-line react-hooks/immutability + navigateWrapper.go = router.go; + + return navigateWrapper; + }, [router]); +} diff --git a/src/react/router/use-route-params.ts b/src/react/router/use-route-params.ts new file mode 100644 index 0000000..436bfab --- /dev/null +++ b/src/react/router/use-route-params.ts @@ -0,0 +1,23 @@ +import { useMemo } from 'react'; +import { useLocation } from './use-location.ts'; + +/** + * Returns pathname pattern exec result on current location. + * @param pathnamePattern Pathname pattern. + * @returns Params. + * + * @example + * ```js + * const { userId } = useRouteParams('/user/:userId'); + * ``` + */ +export function useRouteParams(pathnamePattern: string): Record { + const location = useLocation(); + + return useMemo(() => { + // @todo брать реализацию URLPattern из какого-нибудь контекста? + const pattern = new URLPattern({ pathname: pathnamePattern }); + + return pattern.exec({ pathname: location.pathname })?.pathname.groups ?? {}; + }, [location, pathnamePattern]); +} diff --git a/src/router/browser-router.ts b/src/router/browser-router.ts new file mode 100644 index 0000000..d756730 --- /dev/null +++ b/src/router/browser-router.ts @@ -0,0 +1,81 @@ +import type { Router, RouterLocation } from './types.ts'; +import { getStubLocation, normalizeLocation } from './utils.ts'; + +export interface BrowserRouterConfig { + /** Useful when you use BrowserRouter on server. */ + defaultLocation?: RouterLocation; +} + +/** + * Router implementation for using in browser environment. + * Can be used in Node.js (or other server environment) while `.connect()` is not called. + */ +export class BrowserRouter implements Router { + private location: RouterLocation; + private listeners: Set; + + constructor({ defaultLocation }: BrowserRouterConfig = {}) { + this.location = defaultLocation ?? getStubLocation(); + this.listeners = new Set(); + } + + private setLocation(location: RouterLocation): void { + this.location = normalizeLocation(location); + + for (const listener of this.listeners) { + listener(); + } + } + + getLocation(): RouterLocation { + return this.location; + } + + navigate(url: string): void { + const currentUrl = new URL(window.location.href); + const nextUrl = new URL(url, window.location.href); + + if (nextUrl.origin !== currentUrl.origin) { + window.location.href = nextUrl.href; + return; + } + + const location = normalizeLocation(nextUrl); + + window.history.pushState(null, '', `${location.pathname}${location.search}${location.hash}`); + window.scrollTo(0, 0); + this.setLocation(location); + } + + go(delta: number): void { + window.history.go(delta); + } + + connect(): () => void { + const sync = () => { + const url = new URL(window.location.href); + + this.setLocation({ + pathname: url.pathname, + search: url.search, + hash: url.hash, + }); + }; + + sync(); + + window.addEventListener('popstate', sync); + + return () => { + window.removeEventListener('popstate', sync); + }; + } + + subscribe(listener: VoidFunction): () => void { + this.listeners.add(listener); + + return () => { + this.listeners.delete(listener); + }; + } +} diff --git a/src/router/mod.ts b/src/router/mod.ts new file mode 100644 index 0000000..84bb4e8 --- /dev/null +++ b/src/router/mod.ts @@ -0,0 +1,2 @@ +export type { Router, RouterLocation } from './types.ts'; +export { type BrowserRouterConfig, BrowserRouter } from './browser-router.ts'; diff --git a/src/router/types.ts b/src/router/types.ts new file mode 100644 index 0000000..4857e7a --- /dev/null +++ b/src/router/types.ts @@ -0,0 +1,13 @@ +export interface RouterLocation { + pathname: string; + hash: string; + search: string; +} + +export interface Router { + getLocation(): RouterLocation; + navigate(url: string): void; + go(delta: number): void; + connect(): () => void; + subscribe(listener: VoidFunction): () => void; +} diff --git a/src/router/utils.ts b/src/router/utils.ts new file mode 100644 index 0000000..eeaf351 --- /dev/null +++ b/src/router/utils.ts @@ -0,0 +1,39 @@ +import type { RouterLocation } from './types.ts'; + +/** + * Returns stub location. + * @returns Location. + * @internal + */ +export function getStubLocation(): RouterLocation { + return { + pathname: '/', + hash: '', + search: '', + }; +} + +/** + * Normalizes pathname in location or URL object. + * Does not mutate given object, returns new object. + * @param location Location (or URL object). + * @returns Location. + * @internal + */ +export function normalizeLocation(location: RouterLocation): RouterLocation { + return { + pathname: normalizePathname(location.pathname), + hash: location.hash, + search: location.search, + }; +} + +/** + * Normalizes pathname. + * @param pathname Pathname. + * @returns Normalized pathname. + * @internal + */ +export function normalizePathname(pathname: string): string { + return pathname.replace(/\/+$/, '') || '/'; +}