# jg-react-router

> React Router helpers: a page outlet that focuses each new page and restores scroll on Back and Forward, and dialogs that close when the route changes.

- **Version:** `0.2.0`
- **Runtimes:** Browser
- **Install:** `npm install jg-react-router`
- **On npm:** [jg-react-router](https://www.npmjs.com/package/jg-react-router)
- **Source:** [packages/jg-react-router](https://github.com/jesusgraterol/jg-packages/tree/main/packages/jg-react-router)
- **Recipes:** [jg-react-router recipes](https://jg-packages.jesusgraterol.dev/packages/jg-react-router/recipes/index.md)

## Usage

### A page outlet

`PageOutlet` renders the matched child route in a frame. Each new page gets focus, without scrolling, so assistive technology starts reading at it, and the window scrolls to the top. Back and Forward restore where the page was scrolled instead, retrying while its content loads, until the user scrolls or types. A navigation that changes only the query, such as a filter, keeps the page and its scroll.

```tsx
import { createBrowserRouter } from 'react-router';
import { PageOutlet } from 'jg-react-router';
import 'jg-react-router/styles.css';

export const router = createBrowserRouter([
  {
    path: '/',
    element: <PageOutlet className="app-main" />,
    children: [
      { index: true, element: <HomePage /> },
      { path: 'settings', element: <SettingsPage /> },
    ],
  },
]);
```

The stylesheet gives the frame its full width and a short fade, which reduced motion turns off. It sits in the `base` cascade layer, so the app's own styles and Tailwind CSS utilities override it. A layout whose nested routes should share one frame, such as a settings page with tabs, passes its own `transitionKey`.

### Closing dialogs when the route changes

A dialog left open across a navigation covers the new page. `DialogNavigationProvider` closes the dialogs that are open when a navigation commits, before the new page's effects run:

```tsx
import { RouterProvider } from 'react-router';
import { DialogNavigationProvider } from 'jg-react-router';

export const App = () => (
  <DialogNavigationProvider>
    <RouterProvider router={router} />
    <ConfirmDialogHost />
  </DialogNavigationProvider>
);
```

The router's root route renders `<DialogNavigationObserver />`, and its error element `<DialogNavigationObserver isErrorScreen />`, so a route error closes them too. Each dialog host calls `useDialogNavigationDismissal` with a reader for its open session, an object that stays the same while the dialog is open, and a function that closes it. Hosts that share a session close it once, and without a provider the hook does nothing.

### Page routes with their metadata

Keep each page's path, title, and description in one route map, and let `createPageRouteObject` build the router's routes from it. Each page component receives exactly `{ title, description }` as its props, and `usePageRouteMetadata` reads the deepest matched page's, such as for the document's title:

```tsx
import { createBrowserRouter } from 'react-router';
import {
  createLazyPageComponent,
  createPageRouteObject,
  type IPageRouteDefinition,
  PageOutlet,
} from 'jg-react-router';

const PAGES: IPageRouteDefinition<{ navigationLabel: string }>[] = [
  {
    path: '/',
    title: 'Home',
    description: 'Your dashboard.',
    navigationLabel: 'Home',
    Component: HomePage,
  },
  {
    path: '/settings',
    title: 'Settings',
    description: 'Your account and notifications.',
    navigationLabel: 'Settings',
    lazy: createLazyPageComponent(() => import('./settings.page.js')), // loads when visited
  },
];

export const router = createBrowserRouter([
  { element: <PageOutlet />, children: PAGES.map((page) => createPageRouteObject(page)) },
]);
```

An eager page becomes `{ path, handle, element }`, and a lazy one `{ path, handle, HydrateFallback, lazy }`, whose `lazy` resolves to the element. The `wrapElement` option wraps every page's element, such as in an error boundary, and `HydrateFallback` shows while a lazy page loads during hydration. `usePageRouteMetadata` skips a handle without a string title and description, and gives `null` when no matched route is a page.

## <a id="api-reference"></a>jg-react-router API reference

Every export of `jg-react-router@0.2.0`, 21 in all, with its signature, its errors, and an example. It's generated from the package's TSDoc.

- Functions: [`createLazyPageComponent`](#api-createLazyPageComponent), [`createPageRouteElement`](#api-createPageRouteElement), [`createPageRouteHandle`](#api-createPageRouteHandle), [`createPageRouteMetadata`](#api-createPageRouteMetadata), [`createPageRouteObject`](#api-createPageRouteObject), [`DialogNavigationObserver`](#api-DialogNavigationObserver), [`DialogNavigationProvider`](#api-DialogNavigationProvider), [`PageOutlet`](#api-PageOutlet), [`useDialogNavigationDismissal`](#api-useDialogNavigationDismissal), [`usePageRouteMetadata`](#api-usePageRouteMetadata)
- Types: [`ICreatePageRouteObjectOptions`](#api-ICreatePageRouteObjectOptions), [`IDialogNavigationDismissalOptions`](#api-IDialogNavigationDismissalOptions), [`IDialogNavigationObserverProps`](#api-IDialogNavigationObserverProps), [`IDialogNavigationProviderProps`](#api-IDialogNavigationProviderProps), [`IDialogNavigationSessionReader`](#api-IDialogNavigationSessionReader), [`IPageOutletProps`](#api-IPageOutletProps), [`IPageRouteComponentProps`](#api-IPageRouteComponentProps), [`IPageRouteDefinition`](#api-IPageRouteDefinition), [`IPageRouteHandle`](#api-IPageRouteHandle), [`IPageRouteLazyFunction`](#api-IPageRouteLazyFunction), [`IPageRouteMetadata`](#api-IPageRouteMetadata)

### <a id="api-functions"></a>Functions

#### <a id="api-createLazyPageComponent"></a>`createLazyPageComponent`

*Function*

Turns a dynamic import of a module whose default export is a page component into a page route's `lazy` function, so the page's code loads only when its route is visited.

```ts
createLazyPageComponent(
  loadModule: () => Promise<{ default: ComponentType<IPageRouteMetadata> }>,
): IPageRouteLazyFunction
```

**Parameters**

- `loadModule` `() => Promise<{ default: ComponentType<IPageRouteMetadata> }>`: Imports the page's module.

**Returns** `IPageRouteLazyFunction`: The lazy function.

**Example**

```ts
import { createLazyPageComponent } from 'jg-react-router';

declare const importSettings: Parameters<typeof createLazyPageComponent>[0];

const lazy = createLazyPageComponent(importSettings); // such as () => import('./settings.page.js')
```

#### <a id="api-createPageRouteElement"></a>`createPageRouteElement`

*Function*

Renders a page component with its title and description as its props.

```ts
createPageRouteElement(
  PageComponent: ComponentType<IPageRouteMetadata>,
  pageMetadata: IPageRouteMetadata,
): ReactElement
```

**Parameters**

- `PageComponent` `ComponentType<IPageRouteMetadata>`: The component.
- `pageMetadata` `IPageRouteMetadata`: The title and description.

**Returns** `ReactElement`: The element.

**Example**

```ts
import { createPageRouteElement, type IPageRouteComponentProps } from 'jg-react-router';

declare const SettingsPage: (props: IPageRouteComponentProps) => null;

const element = createPageRouteElement(SettingsPage, { title: 'Settings', description: 'Your account.' });
```

#### <a id="api-createPageRouteHandle"></a>`createPageRouteHandle`

*Function*

Creates the handle a page route carries, which usePageRouteMetadata reads.

```ts
createPageRouteHandle(pageMetadata: IPageRouteMetadata): IPageRouteHandle
```

**Parameters**

- `pageMetadata` `IPageRouteMetadata`: The page's title and description.

**Returns** `IPageRouteHandle`: The handle.

**Example**

```ts
import { createPageRouteHandle } from 'jg-react-router';

const handle = createPageRouteHandle({ title: 'Settings', description: 'Your account.' });
```

#### <a id="api-createPageRouteMetadata"></a>`createPageRouteMetadata`

*Function*

Picks a page's title and description from its route definition, leaving out every other field.

```ts
createPageRouteMetadata(pageRoute: IPageRouteMetadata): IPageRouteMetadata
```

**Parameters**

- `pageRoute` `IPageRouteMetadata`: The definition, or anything with a title and a description.

**Returns** `IPageRouteMetadata`: The metadata.

**Example**

```ts
import { createPageRouteMetadata } from 'jg-react-router';

const settings = { path: '/settings', title: 'Settings', description: 'Your account.' };
createPageRouteMetadata(settings); // { title: 'Settings', description: 'Your account.' }
```

#### <a id="api-createPageRouteObject"></a>`createPageRouteObject`

*Function*

Turns a page route's definition into a React Router route object: the page's element, with its title and description as its props, under a handle that usePageRouteMetadata reads. An eager route renders its component; a lazy one loads it when visited, with `HydrateFallback` shown while it loads during hydration. `wrapElement` wraps the element either way.

```ts
createPageRouteObject<TExtraFields extends object>(
  pageRoute: IPageRouteDefinition<TExtraFields>,
  options?: ICreatePageRouteObjectOptions,
): RouteObject
```

**Parameters**

- `pageRoute` `IPageRouteDefinition<TExtraFields>`: The definition.
- `options?` `ICreatePageRouteObjectOptions`: The fallback for lazy routes, and the wrapper for every element. Defaults to `{}`.

**Returns** `RouteObject`: The route object.

**Example**

```ts
import { createBrowserRouter } from 'react-router';
import { createLazyPageComponent, createPageRouteObject } from 'jg-react-router';

declare const importSettings: Parameters<typeof createLazyPageComponent>[0];

export const router = createBrowserRouter([
  createPageRouteObject({
    path: '/settings',
    title: 'Settings',
    description: 'Your account and notifications.',
    lazy: createLazyPageComponent(importSettings),
  }),
]);
```

#### <a id="api-DialogNavigationObserver"></a>`DialogNavigationObserver`

*Function*

Reports each committed navigation to the surrounding DialogNavigationProvider, in a layout effect, so open dialogs close before the new page's effects open its own. Render it once in the router's root route, and with `isErrorScreen` in its error element, so a route error closes the dialogs too. Outside a provider, it renders nothing and needs no router.

```ts
DialogNavigationObserver(
  props: IDialogNavigationObserverProps,
): ReactElement<unknown, string | JSXElementConstructor<any>> | null
```

**Parameters**

- `props` `IDialogNavigationObserverProps`: Whether it belongs to the root error screen.

**Returns** `ReactElement<unknown, string | JSXElementConstructor<any>> | null`: The observer, or null outside a provider.

**Example**

```ts
import { createElement } from 'react';
import { createBrowserRouter, Outlet } from 'react-router';
import { DialogNavigationObserver } from 'jg-react-router';

export const router = createBrowserRouter([
  {
    path: '/',
    element: createElement('div', null, createElement(DialogNavigationObserver), createElement(Outlet)),
    errorElement: createElement(DialogNavigationObserver, { isErrorScreen: true }),
  },
]);
```

#### <a id="api-DialogNavigationProvider"></a>`DialogNavigationProvider`

*Function*

Closes the dialogs that are open when a navigation commits, without touching history: each dialog host registers with useDialogNavigationDismissal, and a DialogNavigationObserver inside the router reports each committed location. It reads every open session before closing any, so a dialog that closes or replaces another can't make the provider skip or close the wrong one, and hosts that share a session close it once. Without a provider, the hook and the observer do nothing.

```ts
DialogNavigationProvider(props: IDialogNavigationProviderProps): ReactElement
```

**Parameters**

- `props` `IDialogNavigationProviderProps`: The router and the dialog hosts.

**Returns** `ReactElement`: The provider.

**Example**

```ts
import { createElement } from 'react';
import { RouterProvider, type createBrowserRouter } from 'react-router';
import { DialogNavigationProvider } from 'jg-react-router';

declare const router: ReturnType<typeof createBrowserRouter>;
declare const DialogHost: () => null;

// the router's root route renders <DialogNavigationObserver />
export const App = () =>
  createElement(
    DialogNavigationProvider,
    null,
    createElement(RouterProvider, { router }),
    createElement(DialogHost),
  );
```

#### <a id="api-PageOutlet"></a>`PageOutlet`

*Function*

Renders the matched child route in a frame that orients each new page: it focuses the frame, without scrolling, and scrolls the window to the top. Back and Forward restore the position the page had instead, retrying while its content loads and grows, until the user scrolls, touches, clicks, or types. A navigation that changes only the query keeps the page and its scroll. While an outlet is mounted, `history.scrollRestoration` is `manual`. Import `jg-react-router/styles.css` for the frame's layout and its fade, which reduced motion turns off.

```ts
PageOutlet(props: IPageOutletProps): ReactElement
```

**Parameters**

- `props` `IPageOutletProps`: The frame's extra classes, and the key that identifies the page.

**Returns** `ReactElement`: The frame with the child route.

**Example**

```ts
import { createElement } from 'react';
import { createBrowserRouter } from 'react-router';
import { PageOutlet } from 'jg-react-router';

declare const HomePage: () => null;
declare const SettingsPage: () => null;

export const router = createBrowserRouter([
  {
    path: '/',
    element: createElement(PageOutlet, { className: 'app-main' }),
    children: [
      { index: true, element: createElement(HomePage) },
      { path: 'settings', element: createElement(SettingsPage) },
    ],
  },
]);
```

#### <a id="api-useDialogNavigationDismissal"></a>`useDialogNavigationDismissal`

*Function*

Closes a dialog when a navigation commits while its session is open, through the surrounding DialogNavigationProvider. It always calls the latest `getSession` and `onDismiss` the component committed, so they needn't be stable, and it closes the session only if `getSession` still returns the one that was open when the navigation committed. Outside a provider, it does nothing.

```ts
useDialogNavigationDismissal(options: IDialogNavigationDismissalOptions): void
```

**Parameters**

- `options` `IDialogNavigationDismissalOptions`: How to read the dialog's session, and how to close it.

**Example**

```ts
import { useState } from 'react';
import { useDialogNavigationDismissal } from 'jg-react-router';

export const useConfirmDialog = () => {
  const [session, setSession] = useState<object | null>(null);
  useDialogNavigationDismissal({
    getSession: () => session,
    onDismiss: () => {
      setSession(null);
    },
  });
  return { isOpen: session !== null, open: () => setSession({}), close: () => setSession(null) };
};
```

#### <a id="api-usePageRouteMetadata"></a>`usePageRouteMetadata`

*Function*

Gives the title and description of the deepest matched page route, such as for the document's title or a page header in a layout. A handle without a string title and description is skipped.

```ts
usePageRouteMetadata(): IPageRouteMetadata | null
```

**Returns** `IPageRouteMetadata | null`: The page's metadata, or null when no matched route is a page route.

**Example**

```ts
import { useEffect } from 'react';
import { usePageRouteMetadata } from 'jg-react-router';

export const useDocumentTitle = () => {
  const pageMetadata = usePageRouteMetadata();
  useEffect(() => {
    document.title = pageMetadata === null ? 'App' : `${pageMetadata.title} | App`;
  }, [pageMetadata]);
};
```

### <a id="api-types"></a>Types

#### <a id="api-ICreatePageRouteObjectOptions"></a>`ICreatePageRouteObjectOptions`

*Type*

what createPageRouteObject takes besides the definition

```ts
type ICreatePageRouteObjectOptions = {
    HydrateFallback?: RouteObject['HydrateFallback'] | undefined;
    wrapElement?: ((element: ReactElement, pageMetadata: IPageRouteMetadata) => ReactElement) | undefined;
};
```

**Members**

- `HydrateFallback: RouteObject["HydrateFallback"]` (property): shown while a lazy page loads during hydration
- `wrapElement: (element: ReactElement, pageMetadata: IPageRouteMetadata) => ReactElement` (property): wraps every page's element, such as in a layout or an error boundary

#### <a id="api-IDialogNavigationDismissalOptions"></a>`IDialogNavigationDismissalOptions`

*Type*

what useDialogNavigationDismissal takes: how to read the dialog's session, and how to close it, which must happen synchronously

```ts
type IDialogNavigationDismissalOptions = {
    getSession: IDialogNavigationSessionReader;
    onDismiss: () => void;
};
```

**Members**

- `getSession: IDialogNavigationSessionReader` (property)
- `onDismiss: () => void` (property)

#### <a id="api-IDialogNavigationObserverProps"></a>`IDialogNavigationObserverProps`

*Type*

what DialogNavigationObserver takes

```ts
type IDialogNavigationObserverProps = {
    isErrorScreen?: boolean | undefined;
};
```

**Members**

- `isErrorScreen: boolean` (property): whether the observer belongs to the root error screen, which replaces the route without changing its location key

#### <a id="api-IDialogNavigationProviderProps"></a>`IDialogNavigationProviderProps`

*Type*

what DialogNavigationProvider takes: the router and the dialog hosts that share its navigation

```ts
type IDialogNavigationProviderProps = {
    children?: ReactNode;
};
```

**Members**

- `children: ReactNode` (property)

#### <a id="api-IDialogNavigationSessionReader"></a>`IDialogNavigationSessionReader`

*Type*

reads a dialog's open session: a stable object for as long as that session lasts, or null while the dialog is closed

```ts
type IDialogNavigationSessionReader = () => object | null;
```

#### <a id="api-IPageOutletProps"></a>`IPageOutletProps`

*Type*

what PageOutlet takes

```ts
type IPageOutletProps = {
    className?: string | undefined;
    transitionKey?: string | undefined;
};
```

**Members**

- `className: string` (property): classes added to the frame after `jg-page-outlet`, such as the app's layout classes
- `transitionKey: string` (property): what identifies the page: a new value remounts the frame, focuses it, and scrolls to the top. Defaults to the location's pathname, so a navigation that changes only the query keeps the page. A parent route's own key keeps its frame while its nested routes change

#### <a id="api-IPageRouteComponentProps"></a>`IPageRouteComponentProps`

*Type*

the props a page component receives: exactly its route's title and description

```ts
type IPageRouteComponentProps = IPageRouteMetadata;
```

#### <a id="api-IPageRouteDefinition"></a>`IPageRouteDefinition`

*Type*

A page route's definition: its path, title, and description, any fields an app adds, such as a navigation label, and either its component or a function that loads it.

```ts
type IPageRouteDefinition<TExtraFields extends object = object> = IPageRouteMetadata & TExtraFields & {
    path: string;
} & ({
    Component: ComponentType<IPageRouteComponentProps>;
    lazy?: never;
} | {
    Component?: never;
    lazy: IPageRouteLazyFunction;
});
```

#### <a id="api-IPageRouteHandle"></a>`IPageRouteHandle`

*Type*

the handle a page route carries, which usePageRouteMetadata reads from the matches

```ts
type IPageRouteHandle = {
    pageMetadata: IPageRouteMetadata;
};
```

**Members**

- `pageMetadata: IPageRouteMetadata` (property)

#### <a id="api-IPageRouteLazyFunction"></a>`IPageRouteLazyFunction`

*Type*

loads a page component on demand, such as from a dynamic import

```ts
type IPageRouteLazyFunction = () => Promise<{
    Component: ComponentType<IPageRouteComponentProps>;
}>;
```

#### <a id="api-IPageRouteMetadata"></a>`IPageRouteMetadata`

*Type*

a page's title and description, kept with its route

```ts
type IPageRouteMetadata = {
    title: string;
    description: string;
};
```

**Members**

- `title: string` (property)
- `description: string` (property)
