# @versini/ui-hooks

[![npm version](https://img.shields.io/npm/v/@versini/ui-hooks?style=flat-square)](https://www.npmjs.com/package/@versini/ui-hooks)
![npm package minimized gzipped size](<https://img.shields.io/bundlejs/size/%40versini%2Fui-hooks?style=flat-square&label=size%20(gzip)>)

> A collection of useful React hooks built with TypeScript for UI component development.

This package provides reusable React hooks that are commonly needed when building UI components, including unique ID generation, click outside detection, keyboard shortcuts, local storage management, viewport tracking, and many other utilities.

## Table of Contents

- [Features](#features)
- [Installation](#installation)
- [Usage](#usage)

## Features

- **🎯 UI-Focused**: Hooks specifically designed for UI component development
- **♿ Accessibility**: Hooks that enhance accessibility (unique IDs, focus management)
- **🔧 TypeScript**: Fully typed with comprehensive type definitions
- **🌲 Tree-shakeable**: Import only the hooks you need
- **⚡ Performance**: Optimized hooks with minimal overhead
- **📱 Responsive**: Hooks for viewport and visual viewport tracking
- **⌨️ Keyboard**: Hotkey and keyboard shortcut management
- **💾 Storage**: Local storage integration with React state

## Installation

```bash
npm install @versini/ui-hooks
```

> **Note**: While this package contains React hooks without styling, when used alongside the UI component packages it assumes TailwindCSS and the `@versini/ui-styles` plugin are configured. See the [installation documentation](https://versini-org.github.io/ui-components/?path=/docs/getting-started-installation--docs) for complete setup instructions.

## Usage

Each hook is exported from its own subpath for optimal tree-shaking:

```tsx
import { useUniqueId } from "@versini/ui-hooks/use-unique-id";
import { useClickOutside } from "@versini/ui-hooks/use-click-outside";
import { useHotkeys, getHotkeyHandler } from "@versini/ui-hooks/use-hotkeys";
import { useHaptic } from "@versini/ui-hooks/use-haptic";
import { useLocalStorage } from "@versini/ui-hooks/use-local-storage";
import { useViewportSize } from "@versini/ui-hooks/use-viewport-size";
import { useVisualViewportSize } from "@versini/ui-hooks/use-visual-viewport-size";
import { useInViewport } from "@versini/ui-hooks/use-in-viewport";
import { useResizeObserver } from "@versini/ui-hooks/use-resize-observer";
import { useInterval } from "@versini/ui-hooks/use-interval";
import { useIsMounted } from "@versini/ui-hooks/use-is-mounted";
import { useMergeRefs } from "@versini/ui-hooks/use-merge-refs";
import { useUncontrolled } from "@versini/ui-hooks/use-uncontrolled";
```

## Available Hooks

### Core Utility Hooks

- **`useUniqueId`** - Generate unique IDs for accessibility
- **`useIsMounted`** - Check if component is mounted
- **`useMergeRefs`** - Merge multiple React refs
- **`useUncontrolled`** - Manage controlled/uncontrolled state

### Interaction Hooks

- **`useClickOutside`** - Detect clicks outside an element
- **`useHotkeys`** - Handle keyboard shortcuts and hotkeys
- **`useHaptic`** - Provide haptic feedback for mobile devices

### Storage Hooks

- **`useLocalStorage`** - Sync state with localStorage

### Viewport and Size Hooks

- **`useViewportSize`** - Track browser viewport dimensions
- **`useVisualViewportSize`** - Track visual viewport (mobile-friendly)
- **`useInViewport`** - Detect if element is visible in viewport
- **`useResizeObserver`** - Observe element size changes

### Timer Hooks

- **`useInterval`** - Manage intervals with start/stop controls

## API Reference

### useUniqueId

Generates a unique ID string for use in components.

```tsx
const id = useUniqueId(prefix?: string): string
```

**Parameters:**

- `prefix` (optional): String prefix for the generated ID

**Returns:** A unique ID string

### useClickOutside

Triggers a callback when clicking outside the target element.

```tsx
const ref = useClickOutside<T>(
  handler: () => void,
  events?: string[] | null,
  nodes?: (HTMLElement | null)[]
): RefObject<T>
```

**Parameters:**

- `handler`: Function called when clicked outside
- `events`: Array of events to listen to (default: `["mousedown", "touchstart"]`)
- `nodes`: Array of additional nodes to check against

**Returns:** Ref to attach to the target element

### useHotkeys

Handle keyboard shortcuts and hotkeys.

```tsx
useHotkeys(
  hotkeys: HotkeyItem[],
  tagsToIgnore?: string[],
  triggerOnContentEditable?: boolean
): void
```

**Parameters:**

- `hotkeys`: Array of `[shortcut, handler, options?]` tuples
- `tagsToIgnore`: HTML tags to ignore (default: `["INPUT", "TEXTAREA", "SELECT"]`)
- `triggerOnContentEditable`: Whether to trigger on contentEditable elements

### useHaptic

Provide haptic feedback for mobile devices using the Vibration API.

```tsx
const { haptic } = useHaptic(): { haptic: (count?: number) => void }
```

**Parameters:**

- `count` (optional): Number of haptic pulses to trigger (default: 1)

**Returns:** Object with `haptic` function to trigger feedback

**Example:**

```tsx
import { useHaptic } from "@versini/ui-hooks/use-haptic";

function HapticButton() {
  const { haptic } = useHaptic();

  return (
    <button onClick={() => haptic(1)}>Tap me (with haptic feedback)</button>
  );
}
```

**Notes:**

- Uses `navigator.vibrate`, and does nothing where that is unavailable
- Haptic duration: 50ms per pulse
- Interval between pulses: 120ms
- Multiple pulses create a vibration pattern for better UX
- **iOS is not covered by this hook.** Safari does not implement the Vibration
  API, and the workaround that used to stand in for it — clicking a hidden
  `<input type="checkbox" switch>` — no longer works now that WebKit requires a
  trusted event on the control itself. To get haptics on iOS, put the `switch`
  attribute on the real control the user taps, the way `@versini/ui-toggle`
  does; a scripted click cannot reach the Taptic engine.

### useLocalStorage

Manage state synchronized with localStorage.

```tsx
const [value, setValue, resetValue, removeValue] = useLocalStorage<T>({
  key: string,
  initialValue?: T
}): [T, (value: T) => void, () => void, () => void]
```

**Parameters:**

- `key`: localStorage key
- `initialValue`: Default value if not found in storage

**Returns:** Tuple of `[value, setValue, resetValue, removeValue]`

### useViewportSize

Track browser viewport dimensions.

```tsx
const { width, height } = useViewportSize(): { width: number, height: number }
```

**Returns:** Object with current viewport width and height

### useVisualViewportSize

Track visual viewport dimensions (accounts for mobile keyboards, zoom).

```tsx
const { width, height } = useVisualViewportSize(): { width: number, height: number }
```

**Returns:** Object with current visual viewport width and height

### useInViewport

Detect if an element is visible in the viewport.

```tsx
const { ref, inViewport } = useInViewport<T>(): { ref: RefCallback<T>, inViewport: boolean }
```

**Returns:** Object with ref to attach to element and visibility boolean

### useResizeObserver

Observe element size changes using ResizeObserver API.

```tsx
const [ref, rect] = useResizeObserver<T>(options?: ResizeObserverOptions): [RefObject<T>, ObserverRect]
```

**Parameters:**

- `options`: ResizeObserver configuration options

**Returns:** Tuple of `[ref, rect]` where rect contains dimensions

### useInterval

Manage intervals with start/stop controls.

```tsx
const { start, stop, active } = useInterval(
  fn: () => void,
  interval: number
): { start: () => void, stop: () => void, active: boolean }
```

**Parameters:**

- `fn`: Function to execute at each interval
- `interval`: Interval time in milliseconds

**Returns:** Object with start/stop functions and active state

### useIsMounted

Check if component is currently mounted.

```tsx
const isMounted = useIsMounted(): () => boolean
```

**Returns:** Function that returns true if component is mounted

### useMergeRefs

Merge multiple React refs into a single ref callback.

```tsx
const mergedRef = useMergeRefs<T>(refs: Array<React.Ref<T>>): RefCallback<T>
```

**Parameters:**

- `refs`: Array of refs to merge

**Returns:** Single ref callback that forwards to all provided refs

### useUncontrolled

Manage controlled/uncontrolled component state patterns.

```tsx
const [value, setValue, isControlled] = useUncontrolled<T>({
  value?: T,
  defaultValue?: T,
  finalValue?: T,
  onChange?: (value: T) => void,
  initialControlledDelay?: number
}): [T, (value: T) => void, boolean]
```

**Parameters:**

- `value`: Controlled value
- `defaultValue`: Initial uncontrolled value
- `finalValue`: Fallback value when others are undefined
- `onChange`: Change handler for controlled mode
- `initialControlledDelay`: Delay before controlled mode activates

**Returns:** Tuple of `[value, setValue, isControlled]`

## Comprehensive Examples

### Accessible Form Field

```tsx
import { useUniqueId } from "@versini/ui-hooks/use-unique-id";

function FormField({ label, helpText, error, ...props }) {
  const fieldId = useUniqueId("field");
  const helperId = useUniqueId("helper");
  const errorId = useUniqueId("error");

  return (
    <div>
      <label htmlFor={fieldId}>{label}</label>
      <input
        id={fieldId}
        aria-describedby={[helpText && helperId, error && errorId]
          .filter(Boolean)
          .join(" ")}
        aria-invalid={!!error}
        {...props}
      />
      {helpText && <div id={helperId}>{helpText}</div>}
      {error && (
        <div id={errorId} role="alert">
          {error}
        </div>
      )}
    </div>
  );
}
```

### Modal with Click Outside and Hotkeys

```tsx
import { useClickOutside } from "@versini/ui-hooks/use-click-outside";
import { useHotkeys } from "@versini/ui-hooks/use-hotkeys";
import { useUniqueId } from "@versini/ui-hooks/use-unique-id";

function Modal({ isOpen, onClose, title, children }) {
  const titleId = useUniqueId("modal-title");
  const descId = useUniqueId("modal-desc");
  const ref = useClickOutside(() => onClose());

  useHotkeys([["Escape", onClose]]);

  if (!isOpen) return null;

  return (
    <div className="modal-overlay">
      <div
        ref={ref}
        role="dialog"
        aria-labelledby={titleId}
        aria-describedby={descId}
        className="modal"
      >
        <h2 id={titleId}>{title}</h2>
        <div id={descId}>{children}</div>
        <button onClick={onClose}>Close</button>
      </div>
    </div>
  );
}
```

### Responsive Component with Viewport Tracking

```tsx
import { useViewportSize } from "@versini/ui-hooks/use-viewport-size";
import { useVisualViewportSize } from "@versini/ui-hooks/use-visual-viewport-size";

function ResponsiveComponent() {
  const viewport = useViewportSize();
  const visualViewport = useVisualViewportSize();

  const isMobile = viewport.width < 768;
  const keyboardVisible = visualViewport.height < viewport.height;

  return (
    <div>
      <p>
        Viewport: {viewport.width}x{viewport.height}
      </p>
      <p>
        Visual Viewport: {visualViewport.width}x{visualViewport.height}
      </p>
      <p>Device: {isMobile ? "Mobile" : "Desktop"}</p>
      {keyboardVisible && <p>Virtual keyboard is visible</p>}
    </div>
  );
}
```

### Auto-Save with Local Storage and Intervals

```tsx
import { useLocalStorage } from "@versini/ui-hooks/use-local-storage";
import { useInterval } from "@versini/ui-hooks/use-interval";
import { useIsMounted } from "@versini/ui-hooks/use-is-mounted";

function AutoSaveEditor() {
  const [content, setContent] = useLocalStorage({
    key: "editor-content",
    initialValue: ""
  });
  const [lastSaved, setLastSaved] = useState<Date | null>(null);
  const isMounted = useIsMounted();

  const { start, stop, active } = useInterval(() => {
    if (isMounted() && content) {
      // Auto-save logic here
      setLastSaved(new Date());
    }
  }, 30000); // Auto-save every 30 seconds

  useEffect(() => {
    if (content) {
      start();
    } else {
      stop();
    }
  }, [content, start, stop]);

  return (
    <div>
      <textarea
        value={content}
        onChange={(e) => setContent(e.target.value)}
        placeholder="Start typing..."
      />
      <div>
        Auto-save: {active ? "Active" : "Inactive"}
        {lastSaved && ` - Last saved: ${lastSaved.toLocaleTimeString()}`}
      </div>
    </div>
  );
}
```

### Lazy Loading with Intersection Observer

```tsx
import { useInViewport } from "@versini/ui-hooks/use-in-viewport";

function LazyImage({ src, alt, placeholder }) {
  const { ref, inViewport } = useInViewport();
  const [loaded, setLoaded] = useState(false);

  return (
    <div ref={ref}>
      {inViewport && !loaded && (
        <img src={placeholder} alt={alt} style={{ filter: "blur(5px)" }} />
      )}
      {inViewport && (
        <img
          src={src}
          alt={alt}
          onLoad={() => setLoaded(true)}
          style={{ display: loaded ? "block" : "none" }}
        />
      )}
    </div>
  );
}
```

### Resizable Panel with Size Tracking

```tsx
import { useResizeObserver } from "@versini/ui-hooks/use-resize-observer";

function ResizablePanel({ children }) {
  const [ref, rect] = useResizeObserver();

  return (
    <div
      ref={ref}
      style={{
        resize: "both",
        overflow: "auto",
        border: "1px solid #ccc",
        minWidth: 200,
        minHeight: 100
      }}
    >
      <div>
        Size: {Math.round(rect.width)}x{Math.round(rect.height)}
      </div>
      {children}
    </div>
  );
}
```

### Haptic Feedback for Interactive UI

```tsx
import { useHaptic } from "@versini/ui-hooks/use-haptic";

function InteractiveCounter() {
  const [count, setCount] = useState(0);
  const { haptic } = useHaptic();

  const increment = () => {
    setCount((c) => c + 1);
    haptic(1); // Single pulse
  };

  const decrement = () => {
    setCount((c) => c - 1);
    haptic(1); // Single pulse
  };

  const reset = () => {
    setCount(0);
    haptic(2); // Double pulse for emphasis
  };

  const celebrate = () => {
    haptic(3); // Triple pulse for celebration
  };

  return (
    <div>
      <h2>Count: {count}</h2>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>Reset</button>
      {count >= 10 && <button onClick={celebrate}>🎉 Celebrate!</button>}
    </div>
  );
}
```

### Advanced Controlled/Uncontrolled Input

```tsx
import { useUncontrolled } from "@versini/ui-hooks/use-uncontrolled";
import { useUniqueId } from "@versini/ui-hooks/use-unique-id";

function AdvancedInput({ value, defaultValue, onChange, label, ...props }) {
  const [inputValue, setInputValue, isControlled] = useUncontrolled({
    value,
    defaultValue,
    finalValue: "",
    onChange
  });

  const inputId = useUniqueId("input");

  return (
    <div>
      <label htmlFor={inputId}>
        {label} {isControlled ? "(Controlled)" : "(Uncontrolled)"}
      </label>
      <input
        id={inputId}
        value={inputValue}
        onChange={(e) => setInputValue(e.target.value)}
        {...props}
      />
    </div>
  );
}
```

## License

MIT
