# jg-react

> React hooks for online status, page visibility, Tailwind CSS breakpoints, and scroll thresholds, each safe in a server render.

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

## Usage

```ts
import {
  isBreakpointAtLeast,
  useBreakpoint,
  useDocumentVisibility,
  useIsWindowScrolledPast,
  useOnlineStatus,
} from 'jg-react';

export const useLayoutState = () => {
  const isOnline = useOnlineStatus(); // the browser's guess, from its network interfaces
  const isVisible = useDocumentVisibility(); // false while the tab is hidden
  const breakpoint = useBreakpoint(); // 'xs' on a phone, then 'sm' to '2xl'
  const isScrolled = useIsWindowScrolledPast(8);
  return { isOnline, isVisible, isDesktop: isBreakpointAtLeast(breakpoint, 'lg'), isScrolled };
};
```

| Hook                                    | Returns                                                                                                 | Re-renders when                                 | In a server render                                   |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------- |
| `useOnlineStatus()`                     | Whether the browser is online                                                                           | It goes online or offline                       | `true`                                               |
| `useDocumentVisibility()`               | Whether the page is visible                                                                             | Its tab is hidden or shown                      | `true`                                               |
| `useBreakpoint()`                       | Tailwind CSS's breakpoint: `xs` below `sm`, up to `2xl`                                                 | The viewport crosses a breakpoint               | `undefined`                                          |
| `useIsWindowScrolledPast(pixels)`       | Whether the window is scrolled further than `pixels`                                                    | The answer changes, not every scroll            | `false`                                              |
| `useFormChanges(options)`               | Whether a form's command differs from the last confirmed one, a submit-time check, and `confirmChanges` | The commands or the baseline change             | Compared as usual                                    |
| `useResendCooldown(options)`            | Whether a resend must wait, the seconds left, and `startCooldown`                                       | Every second while cooling down                 | `{ isCoolingDown: false, remainingSeconds: 0 }`      |
| `useCopyFeedback(options?)`             | Whether a copy just succeeded, and `copy(write)`                                                        | A copy succeeds, and its feedback ends          | `isCopied: false`                                    |
| `useFileUploadQueue(options)`           | The upload queue's items, summary, announcement, and controls                                           | The queue changes                               | An empty queue                                       |
| `useResolvedTheme(preference)`          | `light` or `dark`, following the system while the preference is `system`                                | The system's color scheme changes, for `system` | `dark` for `dark`, otherwise `light`                 |
| `useAppliedTheme(preference, options?)` | The theme it sets as the `light` or `dark` class on `<html>`                                            | As `useResolvedTheme`                           | As `useResolvedTheme`, without touching the document |

Keep a save button disabled until the form holds a real change, and a resend button waiting after each send:

```ts
import { useFormChanges, useResendCooldown } from 'jg-react';

const trim = ({ name }: { name: string }) => ({ name: name.trim() });

export const useProfileEditor = (saved: { name: string }, draft: { name: string }) => {
  // a whitespace-only edit isn't a change, and the baseline advances only once a save succeeds
  const { hasChanges, confirmChanges } = useFormChanges({
    confirmedCommand: saved,
    currentCommand: draft,
    normalizeCommand: trim,
  });
  // the deadline survives a reload of the tab, apart from other accounts and actions
  const resend = useResendCooldown({ durationSeconds: 60, cooldownKey: 'account-a:verify-email' });
  return { canSave: hasChanges, confirmChanges, resend };
};
```

The browser hooks read the browser through `useSyncExternalStore`, so every component sees the same value in the same render, and a server render gets a fixed value instead of failing. The breakpoints are Tailwind CSS's defaults, in `rem`, read through media queries, so they follow the browser's font size as CSS does. Prefer CSS for layout, and `useBreakpoint` for what CSS can't do, such as rendering a different component.

### Copy feedback

`useCopyFeedback` shows that a copy worked, for a moment. `copy` takes the function that writes, such as jg-clipboard's `copyText`, and calls it at once, before awaiting anything, so the write stays inside the click that browsers require for clipboard access:

```ts
import { useCopyFeedback } from 'jg-react';

const { isCopied, copy } = useCopyFeedback({ durationSeconds: 1.4, onError: showCopyError });
const onClick = () => void copy(() => navigator.clipboard.writeText(inviteLink)); // resolves to whether it worked
```

`isCopied` stays true for `durationSeconds`, `DEFAULT_COPY_FEEDBACK_SECONDS` (1.4) by default, and a new copy restarts it. A copy while another runs is ignored and resolves to `false`. A failed one resolves to `false` and calls the latest `onError`; `copy` never rejects. After unmount, a copy that settles changes nothing and calls nothing. A duration that isn't positive and finite throws `INVALID_COPY_FEEDBACK_DURATION`.

### File uploads

`useFileUploadQueue` runs a file upload queue without rendering anything, for a drop zone or a file input in the app's own markup:

```ts
import { createFileUploadAcceptValue, useFileUploadQueue } from 'jg-react';

const queue = useFileUploadQueue({
  acceptedFileTypes: ['image/png', 'image/jpeg', '.heic'],
  maxFileBytes: 10 * 1024 * 1024,
  maxFiles: 4,
  isPreviewEnabled: true,
  uploadFile: (file, { signal, reportProgress, uploadId }) =>
    uploadToStorage(file, { signal, onProgress: reportProgress, idempotencyKey: uploadId }),
});
// queue.addFiles(input.files), queue.items, queue.summary, queue.announcement,
// queue.cancelUpload(id), queue.retryUpload(id), queue.removeFile(id), queue.reset()
const accept = createFileUploadAcceptValue(queue.policy.acceptedFileTypes);
```

- **Checks:** each selection is checked file by file, in a fixed order: empty, type, size, duplicate, count, and total size, with `existingFiles` counted toward the limits. A `validateFile` function runs next, such as for an image's dimensions, and the built-in checks run again after it, since the queue may have changed meanwhile. Selections are checked one at a time.
- **Uploads:** admitted files upload first in, first out, up to `maxConcurrentUploads` at once, 2 by default, or wait for `startUploads` without `autoUpload`. Progress is coalesced per frame and never goes backwards. At 100%, a file is `processing`: it waits for its result, can't be canceled, and survives removal and reset.
- **Cancel and retry:** a canceled upload is aborted through its signal, and its late result is ignored, but its slot frees only when its promise settles. A retry keeps the item's `uploadId` and starts the next `attemptNumber`.
- **One file:** with `maxFiles` 1, the default, a new file replaces the current one only once it passes its checks, so a rejected replacement leaves the current file alone.
- **Cleanup:** preview object URLs are revoked on remove, replace, reset, and unmount, and an error a callback throws goes to `reportError` without stopping the queue. Shown again after an `<Activity>` hid it, files whose work stopped are canceled.
- **Text:** every message and announcement comes from `DEFAULT_FILE_UPLOAD_MESSAGES`, which `messages` overrides, and `getErrorFeedback` decides what a failed upload shows, `messages.uploadFailed` by default.

A policy that isn't valid, such as a `maxFileBytes` that isn't a positive integer, throws `INVALID_FILE_UPLOAD_POLICY`, with a message for each field. `resolveFileUploadPolicy`, `validateFileUploadCandidate`, `isFileUploadItemCancelable`, and `hasDraggedFiles` work on their own too.

### Light and dark themes

`createThemeInitScript` builds an inline script that applies the stored preference before the page paints, so a server render never flashes the wrong theme, and `useAppliedTheme` keeps the class on `<html>` in step once React runs:

```tsx
import { createThemeInitScript, useAppliedTheme } from 'jg-react';

// jg-storage's createLocalStore('preferences') keeps { theme } under this key
const script = createThemeInitScript({ storageKey: 'jg-storage:preferences', field: 'theme' });

export const ThemeScript = ({ nonce }: { nonce: string }) => (
  <script
    nonce={nonce}
    dangerouslySetInnerHTML={{ __html: script }}
  />
);

export const ThemeRoot = ({ preference }: { preference: 'light' | 'dark' | 'system' }) => {
  useAppliedTheme(preference); // turns transitions off while the class changes
  return null;
};
```

Set `color-scheme` per class in your stylesheet, such as `:root.dark { color-scheme: dark; }`, so form controls and scrollbars follow the theme.

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

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

- Functions: [`createFileUploadAcceptValue`](#api-createFileUploadAcceptValue), [`createThemeInitScript`](#api-createThemeInitScript), [`hasDraggedFiles`](#api-hasDraggedFiles), [`isBreakpointAtLeast`](#api-isBreakpointAtLeast), [`isFileUploadItemCancelable`](#api-isFileUploadItemCancelable), [`resolveFileUploadPolicy`](#api-resolveFileUploadPolicy), [`resolveThemePreference`](#api-resolveThemePreference), [`useAppliedTheme`](#api-useAppliedTheme), [`useBreakpoint`](#api-useBreakpoint), [`useCopyFeedback`](#api-useCopyFeedback), [`useDocumentVisibility`](#api-useDocumentVisibility), [`useFileUploadQueue`](#api-useFileUploadQueue), [`useFormChanges`](#api-useFormChanges), [`useIsWindowScrolledPast`](#api-useIsWindowScrolledPast), [`useOnlineStatus`](#api-useOnlineStatus), [`useResendCooldown`](#api-useResendCooldown), [`useResolvedTheme`](#api-useResolvedTheme), [`validateFileUploadCandidate`](#api-validateFileUploadCandidate)
- Constants: [`DEFAULT_COPY_FEEDBACK_SECONDS`](#api-DEFAULT_COPY_FEEDBACK_SECONDS), [`DEFAULT_FILE_UPLOAD_MESSAGES`](#api-DEFAULT_FILE_UPLOAD_MESSAGES), [`ERRORS`](#api-ERRORS), [`THEME_PREFERENCES`](#api-THEME_PREFERENCES)
- Types: [`IAppliedThemeOptions`](#api-IAppliedThemeOptions), [`IBreakpoint`](#api-IBreakpoint), [`IFileUploadCanceledHandler`](#api-IFileUploadCanceledHandler), [`IFileUploadCanceledItem`](#api-IFileUploadCanceledItem), [`IFileUploadControls`](#api-IFileUploadControls), [`IFileUploadErrorDetails`](#api-IFileUploadErrorDetails), [`IFileUploadErrorFeedback`](#api-IFileUploadErrorFeedback), [`IFileUploadFailedItem`](#api-IFileUploadFailedItem), [`IFileUploadFailureHandler`](#api-IFileUploadFailureHandler), [`IFileUploadHandler`](#api-IFileUploadHandler), [`IFileUploadHandlerContext`](#api-IFileUploadHandlerContext), [`IFileUploadItem`](#api-IFileUploadItem), [`IFileUploadItemsChangeHandler`](#api-IFileUploadItemsChangeHandler), [`IFileUploadMessages`](#api-IFileUploadMessages), [`IFileUploadPolicy`](#api-IFileUploadPolicy), [`IFileUploadPolicyOptions`](#api-IFileUploadPolicyOptions), [`IFileUploadProcessingItem`](#api-IFileUploadProcessingItem), [`IFileUploadProgress`](#api-IFileUploadProgress), [`IFileUploadQueuedItem`](#api-IFileUploadQueuedItem), [`IFileUploadRejectedHandler`](#api-IFileUploadRejectedHandler), [`IFileUploadRejectedItem`](#api-IFileUploadRejectedItem), [`IFileUploadStatus`](#api-IFileUploadStatus), [`IFileUploadSucceededItem`](#api-IFileUploadSucceededItem), [`IFileUploadSuccessHandler`](#api-IFileUploadSuccessHandler), [`IFileUploadSummary`](#api-IFileUploadSummary), [`IFileUploadUploadingItem`](#api-IFileUploadUploadingItem), [`IFileUploadValidatingItem`](#api-IFileUploadValidatingItem), [`IFileUploadValidationIssue`](#api-IFileUploadValidationIssue), [`IFileUploadValidationReason`](#api-IFileUploadValidationReason), [`IFileUploadValidator`](#api-IFileUploadValidator), [`IFileUploadValidatorContext`](#api-IFileUploadValidatorContext), [`IFormChangesComparable`](#api-IFormChangesComparable), [`IResolvedTheme`](#api-IResolvedTheme), [`IThemeInitScriptOptions`](#api-IThemeInitScriptOptions), [`IThemePreference`](#api-IThemePreference), [`IUseCopyFeedbackOptions`](#api-IUseCopyFeedbackOptions), [`IUseCopyFeedbackResult`](#api-IUseCopyFeedbackResult), [`IUseFileUploadQueueOptions`](#api-IUseFileUploadQueueOptions), [`IUseFileUploadQueueResult`](#api-IUseFileUploadQueueResult), [`IUseFormChangesOptions`](#api-IUseFormChangesOptions), [`IUseFormChangesResult`](#api-IUseFormChangesResult), [`IUseResendCooldownOptions`](#api-IUseResendCooldownOptions), [`IUseResendCooldownResult`](#api-IUseResendCooldownResult)

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

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

*Function*

Builds a file input's `accept` attribute from a policy's accepted types.

```ts
createFileUploadAcceptValue(
  acceptedFileTypes: readonly string[],
): string | undefined
```

**Parameters**

- `acceptedFileTypes` `readonly string[]`: The policy's accepted types.

**Returns** `string | undefined`: The attribute's value, or undefined when every type is accepted.

**Example**

```ts
import { createFileUploadAcceptValue } from 'jg-react';

createFileUploadAcceptValue(['image/png', '.jpg']); // 'image/png,.jpg'
```

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

*Function*

Builds the inline script that applies the stored theme before the page paints, so a server render doesn't flash the wrong theme. It reads the preference from localStorage, as JSON or as a raw string, from a field of a stored object when `field` is given, falls back to `defaultPreference` when it's missing or invalid, and sets the `light` or `dark` class on `<html>`. Storage that can't be read and a browser without matchMedia never make it throw.

Render it at the top of `<head>`, with your CSP nonce, as `<script nonce={nonce} dangerouslySetInnerHTML={{ __html: script }} />`. Its output is the same for the same options, so a hash-based CSP works too. A jg-storage store's key is `jg-storage:<id>`. Set `color-scheme` per class in your stylesheet.

```ts
createThemeInitScript(options: IThemeInitScriptOptions): string
```

**Parameters**

- `options` `IThemeInitScriptOptions`: The localStorage key, and optionally the field and the default preference.

**Returns** `string`: The script's source.

**Throws**

- `INVALID_THEME_SCRIPT_OPTIONS`: The theme script needs a non-empty storage key, a non-empty field when one is given, and a valid default preference.

**Example**

```ts
import { createThemeInitScript } from 'jg-react';

const script = createThemeInitScript({ storageKey: 'jg-storage:preferences', field: 'theme' });
```

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

*Function*

Checks whether a drag carries files, so a drop zone can react to it.

```ts
hasDraggedFiles(dragTypes: readonly string[] | DOMStringList): boolean
```

**Parameters**

- `dragTypes` `readonly string[] | DOMStringList`: The drag's types, such as `event.dataTransfer.types`.

**Returns** `boolean`: Whether it carries files.

**Example**

```ts
import { hasDraggedFiles } from 'jg-react';

const onDragOver = (event: DragEvent) => {
  if (event.dataTransfer !== null && hasDraggedFiles(event.dataTransfer.types)) {
    event.preventDefault();
  }
};
```

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

*Function*

Checks whether a breakpoint is at least as wide as another, such as to show a sidebar from md up. An unknown breakpoint, as in a server render, is never wide enough.

```ts
isBreakpointAtLeast(
  breakpoint: IBreakpoint | undefined,
  minimum: IBreakpoint,
): boolean
```

**Parameters**

- `breakpoint` `IBreakpoint | undefined`: The current breakpoint, from useBreakpoint.
- `minimum` `IBreakpoint`: The narrowest breakpoint that counts.

**Returns** `boolean`: Whether the breakpoint reaches the minimum.

**Example**

```ts
import { isBreakpointAtLeast } from 'jg-react';

isBreakpointAtLeast('lg', 'md'); // => true
isBreakpointAtLeast('xs', 'sm'); // => false
isBreakpointAtLeast(undefined, 'xs'); // => false
```

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

*Function*

Checks whether an item can still be canceled: it's validating, queued, or sending its bytes. Once every byte is sent, it's processing, and it can't.

```ts
isFileUploadItemCancelable<TResult>(item: IFileUploadItem<TResult>): boolean
```

**Parameters**

- `item` `IFileUploadItem<TResult>`: The item.

**Returns** `boolean`: Whether it can be canceled.

**Example**

```ts
import { isFileUploadItemCancelable } from 'jg-react';

declare const item: Parameters<typeof isFileUploadItemCancelable>[0];

const canCancel = isFileUploadItemCancelable(item);
```

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

*Function*

Checks and normalizes an upload policy: accepted types trimmed, lowercased, and without repeats, the defaults filled in, concurrency capped at maxFiles, and single-file replacement only for one file.

```ts
resolveFileUploadPolicy(options: IFileUploadPolicyOptions): IFileUploadPolicy
```

**Parameters**

- `options` `IFileUploadPolicyOptions`: The policy's fields.

**Returns** `IFileUploadPolicy`: The policy.

**Throws**

- `INVALID_FILE_UPLOAD_POLICY`: The accepted file types must be an array.
- `INVALID_FILE_UPLOAD_POLICY`: Each accepted file type must be a string.
- `INVALID_FILE_UPLOAD_POLICY`: Each accepted file type must be a file extension, such as .png, or a MIME type, such as image/png.
- `INVALID_FILE_UPLOAD_POLICY`: The maximum file size must be a positive integer of bytes.
- `INVALID_FILE_UPLOAD_POLICY`: The maximum file count must be a positive integer.
- `INVALID_FILE_UPLOAD_POLICY`: The maximum concurrent uploads must be a positive integer.
- `INVALID_FILE_UPLOAD_POLICY`: The maximum total size must be a positive integer of bytes, when given.

**Example**

```ts
import { resolveFileUploadPolicy } from 'jg-react';

resolveFileUploadPolicy({ acceptedFileTypes: [' IMAGE/PNG '], maxFileBytes: 1_048_576 });
// { acceptedFileTypes: ['image/png'], maxFiles: 1, maxConcurrentUploads: 1, ... }
```

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

*Function*

Resolves a preference to the theme a page shows: the system's color scheme for `system`.

```ts
resolveThemePreference(
  preference: "light" | "dark" | "system",
  systemPrefersDark: boolean,
): IResolvedTheme
```

**Parameters**

- `preference` `"light" | "dark" | "system"`: The preference.
- `systemPrefersDark` `boolean`: Whether the system uses a dark color scheme.

**Returns** `IResolvedTheme`: `light` or `dark`.

**Example**

```ts
import { resolveThemePreference } from 'jg-react';

resolveThemePreference('system', true); // 'dark'
resolveThemePreference('light', true); // 'light'
```

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

*Function*

Applies a theme preference to the page: the `light` or `dark` class on `<html>`, following the system's color scheme while the preference is `system`. The class changes before the browser paints, with transitions off by default, so colors switch at once. Pair it with createThemeInitScript, so the page shows the right theme before React loads, and set `color-scheme` per class in your stylesheet.

```ts
useAppliedTheme(
  preference: "light" | "dark" | "system",
  options?: IAppliedThemeOptions,
): IResolvedTheme
```

**Parameters**

- `preference` `"light" | "dark" | "system"`: The user's preference, such as from a settings store.
- `options?` `IAppliedThemeOptions`: Whether to turn transitions off while the class changes. Defaults to true. Defaults to `{}`.

**Returns** `IResolvedTheme`: The theme applied, `light` or `dark`.

**Example**

```ts
import { useAppliedTheme } from 'jg-react';

export const ThemeRoot = ({ preference }: { preference: 'light' | 'dark' | 'system' }) => {
  useAppliedTheme(preference);
  return null;
};
```

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

*Function*

Reports the viewport's Tailwind CSS breakpoint, re-rendering only when the viewport crosses one: xs below sm, such as on a phone, then sm, md, lg, xl, and 2xl. A server render, which has no viewport, reports undefined, so prefer CSS for layout and this for what CSS can't do.

```ts
useBreakpoint(): IBreakpoint | undefined
```

**Returns** `IBreakpoint | undefined`: The breakpoint, or undefined in a server render.

**Example**

```ts
import { isBreakpointAtLeast, useBreakpoint } from 'jg-react';

export const useIsDesktopLayout = (): boolean => isBreakpointAtLeast(useBreakpoint(), 'lg');
```

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

*Function*

Shows that a copy worked, for a moment, such as a "Copied" label on a copy button. `copy` calls the write function at once, before it awaits anything, so a clipboard write still runs inside the click that browsers require; then `isCopied` stays true for `durationSeconds`. A copy while another runs is ignored, and a failed one calls `onError` and leaves `isCopied` false. `copy` never rejects, and after unmount nothing changes and nothing is called.

```ts
useCopyFeedback(options?: IUseCopyFeedbackOptions): IUseCopyFeedbackResult
```

**Parameters**

- `options?` `IUseCopyFeedbackOptions`: How long the copied state lasts, and what receives a failed copy's error. Defaults to `{}`.

**Returns** `IUseCopyFeedbackResult`: Whether a copy just succeeded, and `copy`, which keeps its identity.

**Throws**

- `INVALID_COPY_FEEDBACK_DURATION`: The copy feedback duration must be a positive, finite number of seconds.

**Example**

```ts
import { useCopyFeedback } from 'jg-react';

export const useCopyLink = (url: string) => {
  const { isCopied, copy } = useCopyFeedback();
  return { isCopied, copyLink: () => copy(() => navigator.clipboard.writeText(url)) };
};
```

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

*Function*

Reports whether the page is visible, re-rendering when its tab is hidden or shown again, such as to pause polling while nobody looks. A server render reports visible.

```ts
useDocumentVisibility(): boolean
```

**Returns** `boolean`: Whether the page is visible.

**Example**

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

declare const refreshPrices: () => void;

export const usePricePolling = (): void => {
  const isVisible = useDocumentVisibility();
  useEffect(() => {
    if (!isVisible) {
      return undefined;
    }
    const timer = setInterval(refreshPrices, 30_000);
    return () => clearInterval(timer);
  }, [isVisible]);
};
```

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

*Function*

Runs a headless file upload queue, for a drop zone or a file input in the app's own markup. Selections are checked one at a time, against the built-in checks, then `validateFile`, then the built-in checks again, since the queue may have changed meanwhile. Admitted files upload first in, first out, up to `maxConcurrentUploads` at once, with progress coalesced per frame. At 100%, an upload is processing: it can't be canceled anymore, and it survives removal and reset. A canceled upload is aborted and its late result ignored, but its slot frees only once its promise settles. A retry keeps the item's id. With `maxFiles` 1, a new file replaces the current one only once it validates. Object URLs are revoked on remove, replace, reset, and unmount, and errors the callbacks throw go to `reportError`. Shown again after an `<Activity>` hid it, items whose work stopped are canceled.

```ts
useFileUploadQueue<TResult>(
  options: IUseFileUploadQueueOptions<TResult>,
): IUseFileUploadQueueResult<TResult>
```

**Parameters**

- `options` `IUseFileUploadQueueOptions<TResult>`: The policy, the upload and validation functions, the messages, and the callbacks.

**Returns** `IUseFileUploadQueueResult<TResult>`: The controls, which keep their identity, the items, their summary, the latest announcement for a polite live region, and the policy.

**Throws**

- `INVALID_FILE_UPLOAD_POLICY`: The accepted file types must be an array.
- `INVALID_FILE_UPLOAD_POLICY`: Each accepted file type must be a string.
- `INVALID_FILE_UPLOAD_POLICY`: Each accepted file type must be a file extension, such as .png, or a MIME type, such as image/png.
- `INVALID_FILE_UPLOAD_POLICY`: The maximum file size must be a positive integer of bytes.
- `INVALID_FILE_UPLOAD_POLICY`: The maximum file count must be a positive integer.
- `INVALID_FILE_UPLOAD_POLICY`: The maximum concurrent uploads must be a positive integer.
- `INVALID_FILE_UPLOAD_POLICY`: The maximum total size must be a positive integer of bytes, when given.

**Example**

```ts
import { useFileUploadQueue } from 'jg-react';

declare const uploadAvatar: (file: File, signal: AbortSignal) => Promise<{ url: string }>;

export const useAvatarUpload = () =>
  useFileUploadQueue({
    acceptedFileTypes: ['image/png', 'image/jpeg'],
    maxFileBytes: 5 * 1024 * 1024,
    isPreviewEnabled: true,
    uploadFile: (file, { signal }) => uploadAvatar(file, signal),
  });
```

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

*Function*

Tells whether a form holds unsaved changes, by comparing the command it edits with the last one the server confirmed, so a save button can stay disabled until something changed. Commands are compared by value, as JSON: a Map or a Set compares as `{}`, a key whose value is `undefined` doesn't count, and a BigInt throws `UNABLE_TO_SERIALIZE_JSON`. Pass `normalizeCommand` to compare commands the way the server stores them, such as with trimmed text.

The baseline advances only when `confirmedCommand` changes by value, or through `confirmChanges` once a save succeeds, so a failed save keeps the changes. `guardSubmission` checks again as the form submits, in case the command changed since the last render.

```ts
useFormChanges<TCommand extends object, TNormalizedCommand extends object = TCommand>(
  options: IUseFormChangesOptions<TCommand, TNormalizedCommand>,
): IUseFormChangesResult<TCommand>
```

**Parameters**

- `options` `IUseFormChangesOptions<TCommand, TNormalizedCommand>`: The confirmed command, the current one, and the optional normalizer.

**Returns** `IUseFormChangesResult<TCommand>`: Whether the form holds changes, the submit-time check, and `confirmChanges`.

**Throws**

- `UNABLE_TO_SERIALIZE_JSON`: if a command holds a value JSON can't represent, such as a BigInt

**Example**

```ts
import { useFormChanges } from 'jg-react';

type IProfileCommand = { name: string; bio: string };

const trimProfile = ({ name, bio }: IProfileCommand): IProfileCommand => ({
  name: name.trim(),
  bio: bio.trim(),
});

export const useProfileForm = (saved: IProfileCommand, draft: IProfileCommand) => {
  const { hasChanges, guardSubmission, confirmChanges } = useFormChanges({
    confirmedCommand: saved,
    currentCommand: draft,
    normalizeCommand: trimProfile,
  });
  return { canSave: hasChanges, guardSubmission, confirmChanges };
};
```

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

*Function*

Reports whether the window is scrolled past a number of pixels, such as to shadow a sticky header or show a back-to-top button. It re-renders only when the answer changes, not on every scroll. A server render reports false.

```ts
useIsWindowScrolledPast(pixels: number): boolean
```

**Parameters**

- `pixels` `number`: How far down the window must be scrolled, in pixels.

**Returns** `boolean`: Whether it's scrolled further than that.

**Example**

```ts
import { useIsWindowScrolledPast } from 'jg-react';

export const useHeaderShadow = (): string =>
  useIsWindowScrolledPast(8) ? 'shadow-md' : 'shadow-none';
```

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

*Function*

Reports whether the browser is online, re-rendering when it goes online or offline. It's the browser's own guess, from its network interfaces, not proof that a server can be reached, so use it to explain a failure, not to skip a request. A server render reports online.

```ts
useOnlineStatus(): boolean
```

**Returns** `boolean`: Whether the browser is online.

**Example**

```ts
import { useOnlineStatus } from 'jg-react';

export const OfflineBanner = () => {
  const isOnline = useOnlineStatus();
  return isOnline ? null : 'You are offline. Changes will sync when you reconnect.';
};
```

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

*Function*

Makes a resend button, such as for a verification email or a one-time code, wait after each send, with a countdown. Call `startCooldown` once the server acknowledged a send. With a `cooldownKey`, the deadline is kept in sessionStorage, so it survives a reload of the tab, and in memory when storage fails. Until the tab sends itself, `lastAttemptedAt`, when the server says the last send happened, sets the deadline; a time in the future counts as now.

The first render, on the server and while hydrating, is always `{ isCoolingDown: false, remainingSeconds: 0 }`, and the deadline is read right after mount, before the browser paints. So a server-rendered button looks enabled until hydration, when it can't be clicked anyway.

```ts
useResendCooldown(options: IUseResendCooldownOptions): IUseResendCooldownResult
```

**Parameters**

- `options` `IUseResendCooldownOptions`: The duration in seconds, and optionally the cooldown's key, the server's last send, and the jg-storage store id.

**Returns** `IUseResendCooldownResult`: Whether it's cooling down, the seconds left, and `startCooldown`.

**Throws**

- `INVALID_COOLDOWN_DURATION`: The cooldown duration must be a positive safe integer of seconds.
- `INVALID_STORE_ID`: The store id must hold only letters, digits, underscores, and hyphens.

**Example**

```ts
import { useResendCooldown } from 'jg-react';

export const useVerificationResend = (accountId: string, lastSentAt: string | null) =>
  useResendCooldown({
    durationSeconds: 60,
    cooldownKey: `${accountId}:verify-email`,
    lastAttemptedAt: lastSentAt,
  });
```

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

*Function*

Resolves a theme preference to the theme a page shows, following the system's color scheme while the preference is `system`, and re-rendering when it changes. A server render, which can't read the system, gives `dark` for `dark` and `light` otherwise.

```ts
useResolvedTheme(preference: "light" | "dark" | "system"): IResolvedTheme
```

**Parameters**

- `preference` `"light" | "dark" | "system"`: The user's preference.

**Returns** `IResolvedTheme`: `light` or `dark`.

**Example**

```ts
import { useResolvedTheme } from 'jg-react';

export const CodeBlock = ({ preference }: { preference: 'light' | 'dark' | 'system' }) => {
  const theme = useResolvedTheme(preference);
  return theme === 'dark' ? 'github-dark' : 'github-light';
};
```

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

*Function*

Checks a file against a policy and the files already admitted, in a fixed order: empty, type, size, duplicate, count, and total size.

```ts
validateFileUploadCandidate(
  file: File,
  admittedFiles: readonly File[],
  policy: IFileUploadPolicy,
  messages?: IFileUploadMessages,
): IFileUploadValidationIssue | null
```

**Parameters**

- `file` `File`: The file.
- `admittedFiles` `readonly File[]`: The files that count toward the limits.
- `policy` `IFileUploadPolicy`: The policy, from resolveFileUploadPolicy.
- `messages?` `IFileUploadMessages`: The text of the issues. Defaults to DEFAULT_FILE_UPLOAD_MESSAGES. Defaults to `DEFAULT_FILE_UPLOAD_MESSAGES`.

**Returns** `IFileUploadValidationIssue | null`: The first issue, or null when the file is admitted.

**Example**

```ts
import { resolveFileUploadPolicy, validateFileUploadCandidate } from 'jg-react';

const policy = resolveFileUploadPolicy({ acceptedFileTypes: ['image/*'], maxFileBytes: 1024 });
validateFileUploadCandidate(new File([], 'empty.png', { type: 'image/png' }), [], policy);
// { reason: 'empty-file', message: '"empty.png" is empty and cannot be uploaded.', ... }
```

### <a id="api-constants"></a>Constants

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

*Constant*

the seconds the copied state lasts when the options don't set them

```ts
const DEFAULT_COPY_FEEDBACK_SECONDS: 1.4
```

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

*Constant*

the queue's text in English, which messages overrides

```ts
const DEFAULT_FILE_UPLOAD_MESSAGES: IFileUploadMessages
```

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

*Constant*

the codes for every error thrown by the package

```ts
const ERRORS: { INVALID_COOLDOWN_DURATION: "INVALID_COOLDOWN_DURATION"; INVALID_THEME_SCRIPT_OPTIONS: "INVALID_THEME_SCRIPT_OPTIONS"; INVALID_COPY_FEEDBACK_DURATION: "INVALID_COPY_FEEDBACK_DURATION"; INVALID_FILE_UPLOAD_POLICY: "INVALID_FILE_UPLOAD_POLICY" }
```

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

*Constant*

every theme preference a user can pick

```ts
const THEME_PREFERENCES: readonly ["light", "dark", "system"]
```

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

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

*Type*

how useAppliedTheme applies the theme

```ts
type IAppliedThemeOptions = {
    disableTransitionsOnChange?: boolean | undefined;
};
```

**Members**

- `disableTransitionsOnChange: boolean` (property): turns transitions off while the theme class changes, so colors switch at once. Defaults to true

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

*Type*

Tailwind CSS's breakpoints, from the narrowest: xs is below sm, such as a phone

```ts
type IBreakpoint = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
```

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

*Type*

receives a file once it's canceled

```ts
type IFileUploadCanceledHandler = (item: IFileUploadCanceledItem) => void;
```

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

*Type*

a file canceled before every byte was sent, which can be retried

```ts
type IFileUploadCanceledItem = IFileUploadItemBase & {
    status: 'canceled';
};
```

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

*Type*

what controls the queue

```ts
type IFileUploadControls = {
    addFiles: (files: Iterable<File>) => void;
    cancelAllUploads: () => void;
    cancelUpload: (uploadId: string) => void;
    removeFile: (uploadId: string) => void;
    reset: () => void;
    retryUpload: (uploadId: string) => void;
    startUploads: () => void;
};
```

**Members**

- `addFiles: (files: Iterable<File>) => void` (property): validates the files and queues the ones admitted, one selection at a time
- `cancelAllUploads: () => void` (property): cancels every file that hasn't finished sending its bytes
- `cancelUpload: (uploadId: string) => void` (property): cancels a file that hasn't finished sending its bytes
- `removeFile: (uploadId: string) => void` (property): removes a file, canceling it first; a processing file stays
- `reset: () => void` (property): removes every file but the processing ones, canceling them first
- `retryUpload: (uploadId: string) => void` (property): queues a failed or canceled file again, under the same id
- `startUploads: () => void` (property): starts the queued files, without autoUpload

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

*Type*

an error's feedback, with the original error as its cause

```ts
type IFileUploadErrorDetails = IFileUploadErrorFeedback & {
    cause: unknown;
};
```

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

*Type*

what the user sees about an error, as getErrorFeedback gives it

```ts
type IFileUploadErrorFeedback = {
    message: string;
    code: string | null;
};
```

**Members**

- `message: string` (property): the message to show
- `code: string | null` (property): a code the user may quote to support, or null

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

*Type*

a file whose upload failed, with what to show for its error

```ts
type IFileUploadFailedItem = IFileUploadItemBase & {
    status: 'failed';
    error: IFileUploadErrorDetails;
};
```

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

*Type*

receives a file once its upload failed

```ts
type IFileUploadFailureHandler = (item: IFileUploadFailedItem) => void;
```

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

*Type*

uploads one file, resolving to what the app keeps, such as the stored file's URL

```ts
type IFileUploadHandler<TResult> = (file: File, context: IFileUploadHandlerContext) => Promise<TResult>;
```

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

*Type*

what an upload attempt receives besides the file

```ts
type IFileUploadHandlerContext = {
    attemptNumber: number;
    reportProgress: (uploadedBytes: number, totalBytes: number) => void;
    signal: AbortSignal;
    uploadId: string;
};
```

**Members**

- `attemptNumber: number` (property): the attempt's number, from 1
- `reportProgress: (uploadedBytes: number, totalBytes: number) => void` (property): reports the bytes sent so far; reports are coalesced per frame and never go backwards
- `signal: AbortSignal` (property): aborts when the upload is canceled, removed, replaced, or the hook unmounts
- `uploadId: string` (property): the item's id, the same for every attempt, as an idempotency key

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

*Type*

a file in the queue

```ts
type IFileUploadItem<TResult> = IFileUploadValidatingItem | IFileUploadQueuedItem | IFileUploadUploadingItem | IFileUploadProcessingItem | IFileUploadSucceededItem<TResult> | IFileUploadFailedItem | IFileUploadCanceledItem | IFileUploadRejectedItem;
```

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

*Type*

receives the items and their summary each time the queue changes

```ts
type IFileUploadItemsChangeHandler<TResult> = (items: readonly IFileUploadItem<TResult>[], summary: IFileUploadSummary) => void;
```

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

*Type*

the text the queue shows and announces, which an app can translate or reword

```ts
type IFileUploadMessages = {
    emptyFile: (fileName: string) => string;
    unsupportedFileType: (fileName: string, acceptedFileTypes: readonly string[]) => string;
    fileTooLarge: (fileName: string, maxFileBytes: number, excessBytes: number) => string;
    duplicateFile: (fileName: string) => string;
    fileCountExceeded: (maxFiles: number) => string;
    totalSizeExceeded: (fileName: string, maxTotalBytes: number) => string;
    uploadFailed: string;
    fileIssue: (fileName: string, message: string) => string;
    queued: (fileName: string) => string;
    uploadStarted: (fileName: string) => string;
    uploadComplete: (fileName: string) => string;
    canceled: (fileName: string) => string;
    queuedForRetry: (fileName: string) => string;
    queueCleared: string;
    queueClearedWhileProcessing: string;
};
```

**Members**

- `emptyFile: (fileName: string) => string` (property)
- `unsupportedFileType: (fileName: string, acceptedFileTypes: readonly string[]) => string` (property)
- `fileTooLarge: (fileName: string, maxFileBytes: number, excessBytes: number) => string` (property)
- `duplicateFile: (fileName: string) => string` (property)
- `fileCountExceeded: (maxFiles: number) => string` (property)
- `totalSizeExceeded: (fileName: string, maxTotalBytes: number) => string` (property)
- `uploadFailed: string` (property): the message of a failed upload, or of a validator that threw, unless getErrorFeedback gives one
- `fileIssue: (fileName: string, message: string) => string` (property): announcements, for a polite live region
- `queued: (fileName: string) => string` (property)
- `uploadStarted: (fileName: string) => string` (property)
- `uploadComplete: (fileName: string) => string` (property)
- `canceled: (fileName: string) => string` (property)
- `queuedForRetry: (fileName: string) => string` (property)
- `queueCleared: string` (property)
- `queueClearedWhileProcessing: string` (property)

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

*Type*

the policy every file is checked against, as resolveFileUploadPolicy normalizes it

```ts
type IFileUploadPolicy = {
    acceptedFileTypes: readonly string[];
    allowDuplicateFiles: boolean;
    autoUpload: boolean;
    maxConcurrentUploads: number;
    maxFileBytes: number;
    maxFiles: number;
    maxTotalBytes: number | null;
    replaceSingleFile: boolean;
};
```

**Members**

- `acceptedFileTypes: readonly string[]` (property)
- `allowDuplicateFiles: boolean` (property)
- `autoUpload: boolean` (property)
- `maxConcurrentUploads: number` (property)
- `maxFileBytes: number` (property)
- `maxFiles: number` (property)
- `maxTotalBytes: number | null` (property)
- `replaceSingleFile: boolean` (property)

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

*Type*

what resolveFileUploadPolicy takes

```ts
type IFileUploadPolicyOptions = {
    acceptedFileTypes: readonly string[];
    allowDuplicateFiles?: boolean | undefined;
    autoUpload?: boolean | undefined;
    maxConcurrentUploads?: number | undefined;
    maxFileBytes: number;
    maxFiles?: number | undefined;
    maxTotalBytes?: number | undefined;
    replaceSingleFile?: boolean | undefined;
};
```

**Members**

- `acceptedFileTypes: readonly string[]` (property): file extensions, such as `.png`, and MIME types, such as `image/png` or `image/*`; none accepts every type
- `allowDuplicateFiles: boolean` (property): whether a file with the same name, size, type, and date as an admitted one is admitted. Defaults to false
- `autoUpload: boolean` (property): whether queued files start uploading at once. Defaults to true
- `maxConcurrentUploads: number` (property): the most uploads running at once. Defaults to 2, and never more than maxFiles
- `maxFileBytes: number` (property): the largest file accepted, in bytes
- `maxFiles: number` (property): the most files admitted at once, the existing files included. Defaults to 1
- `maxTotalBytes: number` (property): the most bytes admitted at once, the existing files included
- `replaceSingleFile: boolean` (property): with maxFiles 1, whether a new file replaces the current one, once it validates. Defaults to true

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

*Type*

every byte is sent and the upload waits for its result; it can't be canceled anymore

```ts
type IFileUploadProcessingItem = IFileUploadItemBase & {
    status: 'processing';
};
```

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

*Type*

how much of a file is uploaded; without a report yet, its total is the file's size

```ts
type IFileUploadProgress = {
    hasReportedProgress: boolean;
    percentage: number;
    totalBytes: number;
    uploadedBytes: number;
};
```

**Members**

- `hasReportedProgress: boolean` (property)
- `percentage: number` (property)
- `totalBytes: number` (property)
- `uploadedBytes: number` (property)

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

*Type*

a file admitted, waiting for a slot or for startUploads

```ts
type IFileUploadQueuedItem = IFileUploadItemBase & {
    status: 'queued';
};
```

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

*Type*

receives a file the checks refused

```ts
type IFileUploadRejectedHandler = (item: IFileUploadRejectedItem) => void;
```

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

*Type*

a file not admitted, with the check that refused it

```ts
type IFileUploadRejectedItem = IFileUploadItemBase & {
    status: 'rejected';
    validationIssue: IFileUploadValidationIssue;
};
```

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

*Type*

where a file is in its life in the queue

```ts
type IFileUploadStatus = 'validating' | 'queued' | 'uploading' | 'processing' | 'succeeded' | 'failed' | 'canceled' | 'rejected';
```

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

*Type*

a file uploaded, with what the upload resolved to

```ts
type IFileUploadSucceededItem<TResult> = IFileUploadItemBase & {
    status: 'succeeded';
    result: TResult;
};
```

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

*Type*

receives a file once it's uploaded

```ts
type IFileUploadSuccessHandler<TResult> = (item: IFileUploadSucceededItem<TResult>) => void;
```

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

*Type*

the queue's counts by status, and its progress weighted by bytes

```ts
type IFileUploadSummary = {
    activeUploads: number;
    canceledFiles: number;
    failedFiles: number;
    progress: IFileUploadProgress;
    queuedFiles: number;
    rejectedFiles: number;
    succeededFiles: number;
    totalFiles: number;
    validatingFiles: number;
};
```

**Members**

- `activeUploads: number` (property)
- `canceledFiles: number` (property)
- `failedFiles: number` (property)
- `progress: IFileUploadProgress` (property)
- `queuedFiles: number` (property)
- `rejectedFiles: number` (property)
- `succeededFiles: number` (property)
- `totalFiles: number` (property)
- `validatingFiles: number` (property)

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

*Type*

a file sending its bytes

```ts
type IFileUploadUploadingItem = IFileUploadItemBase & {
    status: 'uploading';
};
```

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

*Type*

a file being checked

```ts
type IFileUploadValidatingItem = IFileUploadItemBase & {
    status: 'validating';
};
```

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

*Type*

why a file wasn't admitted, with the message to show

```ts
type IFileUploadValidationIssue = IFileUploadErrorDetails & {
    reason: IFileUploadValidationReason;
};
```

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

*Type*

why a file wasn't admitted: a built-in check, or the app's validator

```ts
type IFileUploadValidationReason = 'custom' | 'duplicate-file' | 'empty-file' | 'file-count-exceeded' | 'file-too-large' | 'total-size-exceeded' | 'unsupported-file-type';
```

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

*Type*

checks a file the built-in checks admitted, such as an image's dimensions, resolving to an issue or null

```ts
type IFileUploadValidator = (file: File, context: IFileUploadValidatorContext) => IFileUploadValidationIssue | null | Promise<IFileUploadValidationIssue | null>;
```

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

*Type*

what the app's validator receives besides the file

```ts
type IFileUploadValidatorContext = {
    admittedFiles: readonly File[];
    signal: AbortSignal;
    uploadId: string;
};
```

**Members**

- `admittedFiles: readonly File[]` (property): the files admitted so far, the existing files included
- `signal: AbortSignal` (property): aborts when the file is canceled or removed while it's validated
- `uploadId: string` (property)

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

*Type*

a command a form edits, compared by value as JSON

```ts
type IFormChangesComparable = object;
```

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

*Type*

the theme a page shows: light or dark

```ts
type IResolvedTheme = 'light' | 'dark';
```

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

*Type*

what createThemeInitScript reads the stored preference from

```ts
type IThemeInitScriptOptions = {
    storageKey: string;
    field?: string | undefined;
    defaultPreference?: IThemePreference | undefined;
};
```

**Members**

- `storageKey: string` (property): the localStorage key holding the preference, such as `jg-storage:preferences`
- `field: string` (property): the field of a stored object that holds the preference, such as `theme`
- `defaultPreference: IThemePreference` (property): the preference used when none is stored or it isn't valid. Defaults to 'system'

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

*Type*

the theme a user picks: light, dark, or whatever the system uses

```ts
type IThemePreference = (typeof THEME_PREFERENCES)[number];
```

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

*Type*

what useCopyFeedback takes

```ts
type IUseCopyFeedbackOptions = {
    durationSeconds?: number | undefined;
    onError?: ((error: unknown) => void) | undefined;
};
```

**Members**

- `durationSeconds: number` (property): how long isCopied stays true after a copy, in seconds: positive and finite. Defaults to DEFAULT_COPY_FEEDBACK_SECONDS
- `onError: (error: unknown) => void` (property): receives a failed copy's error, such as to show why it failed. The latest one is called

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

*Type*

what useCopyFeedback returns

```ts
type IUseCopyFeedbackResult = {
    isCopied: boolean;
    copy: (write: () => unknown) => Promise<boolean>;
};
```

**Members**

- `isCopied: boolean` (property): whether a copy just succeeded, for a moment
- `copy: (write: () => unknown) => Promise<boolean>` (property): calls write at once, inside the click that called copy, and resolves to whether it succeeded. It never rejects, and resolves to false at once while another copy runs

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

*Type*

what useFileUploadQueue takes

```ts
type IUseFileUploadQueueOptions<TResult> = IFileUploadPolicyOptions & {
    uploadFile: IFileUploadHandler<TResult>;
    validateFile?: IFileUploadValidator | undefined;
    disabled?: boolean | undefined;
    existingFiles?: readonly File[] | undefined;
    isPreviewEnabled?: boolean | undefined;
    messages?: Partial<IFileUploadMessages> | undefined;
    getErrorFeedback?: ((error: unknown) => IFileUploadErrorFeedback) | undefined;
    controlsRef?: Ref<IFileUploadControls> | undefined;
    onItemsChange?: IFileUploadItemsChangeHandler<TResult> | undefined;
    onFileRejected?: IFileUploadRejectedHandler | undefined;
    onUploadSuccess?: IFileUploadSuccessHandler<TResult> | undefined;
    onUploadFailure?: IFileUploadFailureHandler | undefined;
    onUploadCanceled?: IFileUploadCanceledHandler | undefined;
};
```

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

*Type*

what useFileUploadQueue returns: its controls, its items and their summary, the latest announcement, and the policy

```ts
type IUseFileUploadQueueResult<TResult> = IFileUploadControls & {
    announcement: string;
    items: readonly IFileUploadItem<TResult>[];
    policy: IFileUploadPolicy;
    summary: IFileUploadSummary;
};
```

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

*Type*

what useFormChanges compares: the command being edited against the last confirmed one

```ts
type IUseFormChangesOptions<TCommand extends IFormChangesComparable, TNormalizedCommand extends IFormChangesComparable = TCommand> = {
    confirmedCommand: TCommand;
    currentCommand: TCommand;
    normalizeCommand?: ((command: TCommand) => TNormalizedCommand) | undefined;
};
```

**Members**

- `confirmedCommand: TCommand` (property): the command as the server last confirmed it, such as the saved profile
- `currentCommand: TCommand` (property): the command the form holds now
- `normalizeCommand: (command: TCommand) => TNormalizedCommand` (property): turns a command into the form both are compared in, such as with trimmed text

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

*Type*

whether the form holds changes, and what advances its baseline

```ts
type IUseFormChangesResult<TCommand extends IFormChangesComparable> = {
    confirmChanges: (command: TCommand) => void;
    guardSubmission: (command: TCommand) => boolean;
    hasChanges: boolean;
};
```

**Members**

- `confirmChanges: (command: TCommand) => void` (property): makes a command the baseline, once the server confirmed saving it
- `guardSubmission: (command: TCommand) => boolean` (property): checks, as the form submits, whether a command still differs from the baseline
- `hasChanges: boolean` (property): whether the current command differs from the baseline

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

*Type*

what useResendCooldown takes

```ts
type IUseResendCooldownOptions = {
    durationSeconds: number;
    cooldownKey?: string | undefined;
    lastAttemptedAt?: string | number | Date | null | undefined;
    storeId?: string | undefined;
};
```

**Members**

- `durationSeconds: number` (property): how long a resend waits after a send, in seconds: a positive integer
- `cooldownKey: string` (property): what the cooldown is for, such as `${accountId}:verify-email`, so it survives a reload of the tab and stays apart from other accounts' and actions'. Without it, nothing is stored
- `lastAttemptedAt: string | number | Date | null` (property): when the server says the last send happened, used until the tab sends one itself
- `storeId: string` (property): the jg-storage session store holding the deadlines: letters, digits, underscores, and hyphens. Defaults to 'resend-cooldowns'

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

*Type*

the cooldown's state, and what starts it

```ts
type IUseResendCooldownResult = {
    isCoolingDown: boolean;
    remainingSeconds: number;
    startCooldown: () => void;
};
```

**Members**

- `isCoolingDown: boolean` (property): whether a resend must wait
- `remainingSeconds: number` (property): the whole seconds left, rounded up, 0 when it needn't wait
- `startCooldown: () => void` (property): starts the cooldown, once the server acknowledged a send
