# React Shared Utility Library

[![CI](https://github.com/PhillipAWells/workspace/actions/workflows/ci.yml/badge.svg)](https://github.com/PhillipAWells/workspace/actions/workflows/ci.yml)
[![npm version](https://img.shields.io/npm/v/@pawells/react-shared.svg)](https://www.npmjs.com/package/@pawells/react-shared)
[![Node](https://img.shields.io/badge/node-%3E%3D22-brightgreen)](https://nodejs.org)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)

## Description

`@pawells/react-shared` is a shared React component and hook library providing MUI-based UI components, a global notification context, an inactivity warning system, and an animated Voronoi background. It is intended for use across `@pawells` React applications that share a MUI + Emotion stack.

## Requirements

- **Node.js** `>=22`
- **`react`** `>=19.0.0` (required peer)
- **`react-dom`** `>=19.0.0` (required peer)
- **`@mui/material`** `>=9.0.0 <10.0.0` (required peer)
- **`@emotion/react`** `>=11.0.0` (required peer)
- **`@emotion/styled`** `>=11.0.0` (required peer)
- **`@mui/icons-material`** `>=9.0.0 <10.0.0` (required peer — imported unconditionally by `StatCard`, which is re-exported from the package's single barrel)
- **`d3-delaunay`** `>=6.0.0` (required peer — imported unconditionally by `VoronoiBackground`, which is re-exported from the package's single barrel)

## Installation

Install the package along with all required peer dependencies:

```sh
npm install @pawells/react-shared react react-dom @mui/material @emotion/react @emotion/styled @mui/icons-material d3-delaunay
```

## Quick Start

Wrap your application (or the relevant subtree) in `NotificationProvider`, then call `useNotification` in any descendant component to display notifications.

```tsx
import { NotificationProvider } from '@pawells/react-shared';

function Root() {
  return (
    <NotificationProvider>
      <App />
    </NotificationProvider>
  );
}
```

```tsx
import { useNotification } from '@pawells/react-shared';

function SaveButton() {
  const { showNotification } = useNotification();

  const handleSave = async () => {
    await saveData();
    showNotification('Changes saved', 'success');
  };

  return <button onClick={handleSave}>Save</button>;
}
```

## API Reference

### Components

#### `InactivityWarningDialog`

A controlled MUI dialog that warns the user about an upcoming session timeout. Displays a live countdown and provides "Stay Logged In" and "Logout Now" actions. The parent component manages open state and all callbacks.

**Props — `InactivityWarningDialogProps`**

| Prop | Type | Required | Description |
|---|---|---|---|
| `open` | `boolean` | Yes | Whether the dialog is open |
| `countdownSeconds` | `number` | Yes | Remaining seconds before session timeout |
| `onStayLoggedIn` | `() => void` | Yes | Called when the user clicks "Stay Logged In" |
| `onLogout` | `() => void \| Promise<void>` | Yes | Called when the user clicks "Logout Now" |

---

#### `LoadingState`

A centered loading spinner with an optional status message. Useful as a React `Suspense` fallback or during async data fetching. Accepts a forwarded `ref` to the outer `div`.

**Props — `LoadingStateProps`**

| Prop | Type | Default | Description |
|---|---|---|---|
| `size` | `'small' \| 'medium' \| 'large'` | `'medium'` | Spinner size variant |
| `message` | `string` | — | Message displayed below the spinner |

---

#### `PageHeader`

A responsive page header rendering a title at `h1` level, an optional subtitle, and optional action elements aligned to the right. Stacks vertically on mobile and displays as a row on desktop.

**Props — `PageHeaderProps`**

| Prop | Type | Required | Description |
|---|---|---|---|
| `title` | `string` | Yes | Main heading text |
| `subtitle` | `string` | No | Secondary text displayed below the title |
| `actions` | `React.ReactNode` | No | Action elements (e.g. buttons) rendered on the right |

---

#### `StatCard`

A MUI `Card` displaying a named metric with an icon and an optional trending indicator chip. Includes a hover lift effect. Requires `@mui/icons-material` as a peer dependency since the component imports trending icons unconditionally.

**Note:** Because the package exposes a single flat barrel (no subpath exports), `@mui/icons-material` must be installed by every consumer, not only those importing `StatCard` — see [Requirements](#requirements).

**Props — `StatCardProps`**

| Prop | Type | Default | Description |
|---|---|---|---|
| `title` | `string` | — | Card heading |
| `value` | `string \| number` | — | Metric value displayed prominently |
| `icon` | `React.ReactNode` | — | Icon element rendered in the card body |
| `color` | `'primary' \| 'success' \| 'warning' \| 'error' \| 'info'` | `'primary'` | Color theme applied to the value and icon |
| `change` | `number` | — | Percentage change; renders a trending chip with an up or down indicator |

---

#### `VoronoiBackground`

A full-viewport animated Voronoi triangle mesh rendered as a `position: fixed` SVG background. Uses a seeded Mulberry32 PRNG for deterministic point generation and D3-Delaunay for triangulation. Regenerates on window resize with a 150 ms debounce.

This component accesses `window` directly and is not compatible with server-side rendering. The `seed` prop is read only on initial mount; changes after mount are ignored. Memoize `primaryColor` and `secondaryColor` objects at the call site to avoid triggering full regeneration on every parent render.

**Note:** Throws a `ReactSharedError` with code `INVALID_COLOR` if `primaryColor` or `secondaryColor` is an invalid hex color string (e.g., `'#xyz'`, `'#12345'`). RGB objects are always valid.

**Props — `VoronoiBackgroundProps`**

| Prop | Type | Default | Description |
|---|---|---|---|
| `primaryColor` | `{ r: number; g: number; b: number } \| string` | — | Primary/start color (RGB object or hex string) |
| `secondaryColor` | `{ r: number; g: number; b: number } \| string` | derived | Secondary/end color; if omitted, derived from `primaryColor` via HSV |
| `seed` | `number` | `Math.floor(Date.now() / 1000)` | Seed for deterministic PRNG (read only on initial mount) |
| `zIndex` | `number` | `-1` | CSS `z-index` for layering |
| `opacity` | `number` | `1` | Opacity of the entire background |
| `overlay` | `string` | — | CSS color string for a translucent overlay, e.g. `'rgba(0,0,0,0.3)'` |
| `blur` | `number` | `0` | Blur amount in pixels applied to the triangles |
| `className` | `string` | — | CSS class applied to the container `div` |
| `style` | `React.CSSProperties` | — | Inline styles for the container `div` |
| `pointerEventsNone` | `boolean` | `true` | When `true`, disables pointer events so clicks pass through |
| `pointCount` | `number` | `98` | Number of internal seed points used for triangulation |
| `edgePointCount` | `number` | `20` | Points placed along each viewport edge for full coverage |
| `minAreaDivisor` | `number` | `750` | Divisor applied to viewport area to compute the minimum triangle area threshold |
| `maxPruningIterations` | `number` | `10` | Maximum small-triangle pruning iterations |

---

### Hooks

#### `useInactivityWarning(config)`

Tracks user activity events (`mousedown`, `keydown`, `scroll`, `touchstart`, `visibilitychange`) and drives a warning countdown before the session expires. Automatically calls `onTimeout` when the countdown reaches zero.

**Note:** Throws a `ReactSharedError` with code `INVALID_INACTIVITY_CONFIG` if `warningSeconds >= timeoutSeconds` (computed from `timeoutMinutes`).

**Parameters — `InactivityWarningConfig`**

| Property | Type | Default | Description |
|---|---|---|---|
| `onTimeout` | `() => void \| Promise<void>` | — | Callback invoked when the session expires |
| `timeoutMinutes` | `number` | `60` | Total inactivity timeout in minutes |
| `warningSeconds` | `number` | `300` | Seconds before timeout at which to show the warning |

**Returns — `UseInactivityWarningReturn`**

| Property | Type | Description |
|---|---|---|
| `isWarningActive` | `boolean` | Whether the warning dialog should be shown |
| `countdownSeconds` | `number` | Remaining seconds when the warning is active |
| `extendSession` | `() => void` | Resets the inactivity timer and hides the warning |
| `dismissWarning` | `() => void` | Hides the warning without resetting the inactivity timer; the session still expires at the original time |

**Example**

```tsx
import { useInactivityWarning, InactivityWarningDialog } from '@pawells/react-shared';

function App() {
  const { isWarningActive, countdownSeconds, dismissWarning, extendSession } =
    useInactivityWarning({
      timeoutMinutes: 30,
      warningSeconds: 60,
      onTimeout: async () => {
        await LoginService.Logout();
        navigate('/login');
      },
    });

  return (
    <InactivityWarningDialog
      open={isWarningActive}
      countdownSeconds={countdownSeconds}
      onStayLoggedIn={extendSession}
      onLogout={async () => {
        await LoginService.Logout();
        navigate('/login');
      }}
    />
  );
}
```

---

### Context

#### `NotificationProvider`

Wraps a component tree with a global notification system backed by MUI `Snackbar` and `Alert`. Supports queuing (up to 3 visible simultaneously), configurable auto-dismiss (default 6 s), manual dismiss, and four severity variants. Positioning is bottom-left on desktop and bottom-center on mobile.

Place `NotificationProvider` near the root of your application so all descendant components can call `useNotification`.

#### `useNotification()`

Returns the `NotificationContextValue` for the nearest `NotificationProvider`. Throws a `ReactSharedError` with code `NOTIFICATION_CONTEXT_ERROR` if called outside of a `NotificationProvider`.

```ts
showNotification(
  message: string,
  severity: 'success' | 'error' | 'warning' | 'info',
  options?: NotificationOptions
): void
```

**`NotificationOptions`**

| Property | Type | Default | Description |
|---|---|---|---|
| `duration` | `number` | `6000` | Auto-dismiss duration in milliseconds |

---

### Errors

#### `ReactSharedError`

The single domain error class thrown by this package (extends `BaseError` from `@pawells/typescript-common`). Every throw site in the package uses one of the codes below.

| Code | Thrown by |
|---|---|
| `INVALID_COLOR` | `VoronoiBackground` (via `parseColorToRgb`) when `primaryColor`/`secondaryColor` is an invalid hex string |
| `INVALID_OVERLAY_COLOR` | `VoronoiBackground` when `overlay` is not a valid CSS color |
| `INVALID_INACTIVITY_CONFIG` | `useInactivityWarning` when `warningSeconds >= timeoutSeconds` |
| `NOTIFICATION_CONTEXT_ERROR` | `useNotification` when called outside of a `NotificationProvider` |

```ts
import { ReactSharedError } from '@pawells/react-shared';

try {
  useNotification();
}
catch (error) {
  if (error instanceof ReactSharedError && error.Code === ReactSharedError.Code.NOTIFICATION_CONTEXT_ERROR) {
    // handle missing provider
  }
}
```

### Types

| Type | Description |
|---|---|
| `ReactSharedError` | Domain error class; see [Errors](#errors) above |
| `InactivityWarningDialogProps` | Props for `InactivityWarningDialog` |
| `LoadingStateProps` | Props for `LoadingState` |
| `PageHeaderProps` | Props for `PageHeader` |
| `StatCardProps` | Props for `StatCard` |
| `VoronoiBackgroundProps` | Props for `VoronoiBackground` |
| `InactivityWarningConfig` | Configuration object passed to `useInactivityWarning` |
| `UseInactivityWarningReturn` | Return value of `useInactivityWarning` |
| `NotificationContextValue` | Shape of the value returned by `useNotification` |
| `NotificationOptions` | Optional configuration for individual `showNotification` calls |

## License

MIT — See [LICENSE](./LICENSE) for details.
