npm package: @rmnddesign/multi-select
Description: A tiny type-safe React hook for managing multi-select checkbox state
Author: rmnddesign
Latest version: 1.0.1

# @rmnddesign/multi-select

A tiny type-safe React hook for managing multi-select checkbox state.

## The Problem

When you have a list of checkboxes where users can select multiple items, you end up writing the same boilerplate code over and over:

```tsx
const [selected, setSelected] = useState<string[]>([])

const toggleItem = (item: string) => {
  setSelected(prev =>
    prev.includes(item)
      ? prev.filter(x => x !== item)  // remove if already selected
      : [...prev, item]               // add if not selected
  )
}

const isSelected = (item: string) => selected.includes(item)
```

This hook does all of that for you.

## Installation

```bash
npm install @rmnddesign/multi-select
```

## Quick Start

```tsx
import { useMultiSelect } from "@rmnddesign/multi-select"

function FruitPicker() {
  const { selected, toggle, isSelected } = useMultiSelect<string>()

  const fruits = ["apple", "banana", "cherry", "mango"]

  return (
    <div>
      <h3>Pick your favorite fruits:</h3>

      {fruits.map((fruit) => (
        <label key={fruit} style={{ display: "block" }}>
          <input
            type="checkbox"
            checked={isSelected(fruit)}
            onChange={() => toggle(fruit)}
          />
          {fruit}
        </label>
      ))}

      <p>You selected: {selected.length === 0 ? "nothing yet" : selected.join(", ")}</p>
    </div>
  )
}
```

**What's happening here:**
- `useMultiSelect<string>()` creates a new multi-select state for string values
- `isSelected(fruit)` returns `true` if that fruit is currently selected
- `toggle(fruit)` adds the fruit if not selected, or removes it if already selected
- `selected` is the array of all currently selected fruits

## Common Examples

### Pre-select some items

Pass an array of initially selected items:

```tsx
// "banana" and "mango" will be checked by default
const { selected, toggle, isSelected } = useMultiSelect(["banana", "mango"])
```

### Limit how many items can be selected

Use the `max` option to set a maximum:

```tsx
// Users can only select up to 3 items
const { selected, toggle, isSelected } = useMultiSelect<string>([], {
  max: 3
})

// When 3 items are selected, toggle() won't add more items
// (but it will still remove items if you click a selected one)
```

### Require at least one selection

Use the `min` option to prevent deselecting below a threshold:

```tsx
// Users must keep at least 1 item selected
const { selected, toggle, isSelected } = useMultiSelect(["apple"], {
  min: 1
})

// If only 1 item is selected, clicking it won't deselect it
```

### Do something when selection changes

Use the `onChange` callback:

```tsx
const { selected, toggle, isSelected } = useMultiSelect<string>([], {
  onChange: (newSelected) => {
    console.log("Selection changed:", newSelected)
    // Maybe save to localStorage, send to server, etc.
  }
})
```

### Select All / Clear All buttons

```tsx
function MyComponent() {
  const { selected, toggle, isSelected, selectAll, clear } = useMultiSelect<string>()

  const allOptions = ["apple", "banana", "cherry", "mango"]

  return (
    <div>
      <button onClick={() => selectAll(allOptions)}>Select All</button>
      <button onClick={() => clear()}>Clear All</button>

      {allOptions.map((option) => (
        <label key={option}>
          <input
            type="checkbox"
            checked={isSelected(option)}
            onChange={() => toggle(option)}
          />
          {option}
        </label>
      ))}
    </div>
  )
}
```

### Working with objects (not just strings)

You can use any type, not just strings. For objects, make sure you're using the same reference:

```tsx
type User = { id: number; name: string }

function UserPicker() {
  // Define users outside or useMemo to keep stable references
  const users: User[] = [
    { id: 1, name: "Alice" },
    { id: 2, name: "Bob" },
    { id: 3, name: "Charlie" },
  ]

  const { selected, toggle, isSelected } = useMultiSelect<User>()

  return (
    <div>
      {users.map((user) => (
        <label key={user.id}>
          <input
            type="checkbox"
            checked={isSelected(user)}
            onChange={() => toggle(user)}
          />
          {user.name}
        </label>
      ))}

      <p>Selected IDs: {selected.map(u => u.id).join(", ")}</p>
    </div>
  )
}
```

**Important:** For objects, `isSelected()` uses reference equality (`===`). This means the exact same object reference must be used. If you're fetching data from an API, consider using IDs (strings/numbers) instead of full objects.

### Using IDs instead of objects (recommended for API data)

```tsx
type User = { id: number; name: string }

function UserPicker() {
  const [users, setUsers] = useState<User[]>([])

  // Store just the IDs, not the full objects
  const { selected, toggle, isSelected } = useMultiSelect<number>()

  useEffect(() => {
    fetch("/api/users")
      .then(res => res.json())
      .then(setUsers)
  }, [])

  return (
    <div>
      {users.map((user) => (
        <label key={user.id}>
          <input
            type="checkbox"
            checked={isSelected(user.id)}  // check by ID
            onChange={() => toggle(user.id)}  // toggle by ID
          />
          {user.name}
        </label>
      ))}

      <p>Selected IDs: {selected.join(", ")}</p>

      {/* If you need the full objects: */}
      <p>Selected users: {users.filter(u => selected.includes(u.id)).map(u => u.name).join(", ")}</p>
    </div>
  )
}
```

## Full API Reference

### The hook

```tsx
const result = useMultiSelect<T>(initialSelected?, options?)
```

**Parameters:**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `initialSelected` | `T[]` | `[]` | Items that should be selected when the component first renders |
| `options` | `object` | `{}` | Configuration options (see below) |

**Options:**

| Option | Type | Description |
|--------|------|-------------|
| `max` | `number` | Maximum number of items that can be selected. When reached, `toggle()` and `select()` won't add more. |
| `min` | `number` | Minimum number of items that must stay selected. When reached, `toggle()` and `deselect()` won't remove more. |
| `onChange` | `(selected: T[]) => void` | Function called whenever the selection changes. Receives the new array of selected items. |

### What you get back

| Property | Type | Description |
|----------|------|-------------|
| `selected` | `T[]` | Array of currently selected items |
| `toggle(item)` | `(item: T) => void` | Add item if not selected, remove if selected |
| `isSelected(item)` | `(item: T) => boolean` | Check if an item is currently selected |
| `select(item)` | `(item: T) => void` | Add an item (does nothing if already selected) |
| `deselect(item)` | `(item: T) => void` | Remove an item (does nothing if not selected) |
| `selectAll(items)` | `(items: T[]) => void` | Add multiple items at once |
| `clear()` | `() => void` | Remove all selected items |
| `setSelected(items)` | `(items: T[]) => void` | Replace the entire selection with a new array |

## TypeScript

The hook is fully type-safe. Specify your type in angle brackets:

```tsx
// For strings
const { selected } = useMultiSelect<string>()
// selected is string[]

// For numbers
const { selected } = useMultiSelect<number>()
// selected is number[]

// For custom types
type Tag = { id: string; label: string }
const { selected } = useMultiSelect<Tag>()
// selected is Tag[]
```

If you pass initial values, TypeScript will infer the type automatically:

```tsx
// TypeScript knows this is useMultiSelect<string>
const { selected } = useMultiSelect(["apple", "banana"])
```

## License

MIT