# Recipient roster

> One list of people with a switch per channel, per-channel limits, paste-to-add and inline validation.

```tsx
"use client"

import * as React from "react"

import { peopleFromLists } from "@/lib/notify-envelope"
import {
  RecipientRoster,
  type RosterChannel,
  type RosterPerson,
} from "@/components/ui/recipient-roster"

const channels: RosterChannel[] = [
  {
    key: "automatic",
    label: "Each finish",
    hint: "as staff complete",
    limit: 3,
  },
  { key: "onDemand", label: "Digest", hint: "only when sent", limit: 3 },
]

// Fictional data. Nothing here is saved anywhere.
export function RecipientRosterDemo() {
  const [people, setPeople] = React.useState<RosterPerson[]>(() =>
    peopleFromLists(
      {
        automatic: ["mara@example.org"],
        onDemand: ["mara@example.org", "jules@example.org"],
      },
      channels.map((channel) => channel.key)
    )
  )

  return (
    <RecipientRoster
      className="w-full max-w-xl"
      people={people}
      channels={channels}
      onChange={setPeople}
      footnote="No one is set to hear as staff finish, so those notices go to the admin team instead."
    />
  )
}
```

Use it when the same people can hear about something in more than one way: an email as things happen, a digest when someone sends it, or both. Each person appears once, with a switch for every channel. Your storage can stay one list per channel, and [`notify-envelope`](https://realgood.site/docs/components/notify-envelope.md) converts between the two shapes.

## Installation

**Command**

```bash
npx shadcn@latest add https://realgood.site/r/recipient-roster.json
```

Or, with the [`@crisp` namespace](https://realgood.site/docs/installation.md) set up:

```bash
npx shadcn@latest add @crisp/recipient-roster
```

This also adds the shadcn `button`, `input` and `switch`, and `lucide-react`.

**Manual**

**Step 1.** Copy and paste the following code into your project.

```tsx title="components/ui/recipient-roster.tsx"
"use client"

import * as React from "react"
import { Plus, X } from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"

export interface RosterChannel {
  /** Matches a key in each person's `channels`. */
  key: string
  /** Column heading, e.g. "Each finish". */
  label: string
  /** Cadence, e.g. "as staff complete" or "only when sent". Hidden on narrow screens. */
  hint?: string
  /** Maximum people on this channel. Switches lock at the limit. */
  limit?: number
  /** Accessible name for a switch; receives the person's address. */
  switchLabel?: (email: string) => string
}

/** Same shape as `RosterPerson` in `notify-envelope`, so the two fit together. */
export interface RosterPerson {
  /** The person's address, lowercase. */
  email: string
  /** Whether they are on each channel, by key. A missing key counts as off. */
  channels: Record<string, boolean>
}

// Deliberately close to what servers accept (e.g. zod's .email()): no empty or
// dotted-edge local parts, no doubled dots, a real domain and a 2+ letter TLD.
// Pass `validate` to match your own backend exactly.
const EMAIL =
  /^(?!\.)(?!.*\.\.)[^\s@,;]+(?<!\.)@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i

export interface RecipientRosterProps
  extends Omit<React.ComponentProps<"div">, "onChange"> {
  /** The current list, including unsaved edits. Addresses must be lowercase. */
  people: RosterPerson[]
  /** One column and one switch per entry. */
  channels: RosterChannel[]
  /** Called with the full next list after an add, remove or toggle. */
  onChange: (people: RosterPerson[]) => void
  /** Disables the switches, the remove buttons and the input, e.g. while saving. */
  disabled?: boolean
  /** Return true if the address is acceptable. Defaults to a simple shape check. */
  validate?: (email: string) => boolean
  emptyMessage?: React.ReactNode
  /** One line under the list, e.g. what happens when nobody is set. */
  footnote?: React.ReactNode
}

/**
 * One list of people, one switch per channel. People appear once, however many
 * channels they are on; the storage behind it can stay one list per channel.
 */
function RecipientRoster({
  people,
  channels,
  onChange,
  disabled = false,
  validate = (email) => EMAIL.test(email),
  emptyMessage = "Add an address below, then choose what each person receives.",
  footnote,
  className,
  ref,
  ...props
}: RecipientRosterProps) {
  const [draft, setDraft] = React.useState("")
  const [error, setError] = React.useState("")
  // Polite announcement for changes that are otherwise only visible (a row appearing).
  const [status, setStatus] = React.useState("")
  const errorId = React.useId()
  const limitId = React.useId()
  const rootRef = React.useRef<HTMLDivElement>(null)
  React.useImperativeHandle(ref, () => rootRef.current as HTMLDivElement)
  const inputRef = React.useRef<HTMLInputElement>(null)
  // Position and address of a row just removed, so focus is not lost with it.
  const removed = React.useRef<{ index: number; email: string } | null>(null)

  React.useEffect(() => {
    const gone = removed.current
    if (!gone || people.some((p) => p.email === gone.email)) return
    removed.current = null
    const buttons =
      rootRef.current?.querySelectorAll<HTMLButtonElement>(
        "[data-roster-remove]:not(:disabled)"
      ) ?? []
    const next = buttons[Math.min(gone.index, buttons.length - 1)]
    ;(next ?? inputRef.current)?.focus()
  }, [people])

  const count = (key: string) => people.filter((p) => p.channels[key]).length

  function addEmails(raw: string) {
    const candidates = [
      ...new Set(
        raw
          .split(/[\s,;]+/)
          .map((item) => item.trim().toLowerCase())
          .filter(Boolean)
      ),
    ]
    if (candidates.length === 0) return
    const bad = candidates.find((item) => !validate(item))
    if (bad) {
      setError(`“${bad}” doesn’t look like an email address.`)
      return
    }
    setError("")
    const added = candidates.filter(
      (email) => !people.some((p) => p.email === email)
    )
    setStatus(
      added.length === 0
        ? "Already in the list."
        : `Added ${added.length === 1 ? added[0] : `${added.length} addresses`}. Choose what ${added.length === 1 ? "they receive" : "each receives"}.`
    )
    onChange([
      ...people,
      ...added.map((email) => ({
        email,
        channels: Object.fromEntries(channels.map((c) => [c.key, false])),
      })),
    ])
    setDraft("")
  }

  function toggle(email: string, key: string, on: boolean) {
    setError("")
    onChange(
      people.map((p) =>
        p.email === email ? { ...p, channels: { ...p.channels, [key]: on } } : p
      )
    )
  }

  return (
    <div
      ref={rootRef}
      data-slot="recipient-roster"
      className={cn(
        "[--roster-col:3.5rem] sm:[--roster-col:6.75rem]",
        className
      )}
      {...props}
    >
      <div
        role="group"
        aria-label="Recipients"
        className="grid items-center"
        style={{
          gridTemplateColumns: `minmax(0,1fr) repeat(${channels.length}, var(--roster-col)) 2rem`,
        }}
      >
        <div aria-hidden="true" />
        {channels.map((c) => (
          <div
            key={c.key}
            className="pb-2 text-center text-xs tracking-wide text-muted-foreground uppercase"
          >
            {c.label}
            <span
              id={`${limitId}-${c.key}`}
              className="block tracking-normal normal-case tabular-nums"
            >
              {count(c.key)}
              {c.limit ? ` / ${c.limit}` : ""}
              {c.limit && count(c.key) >= c.limit ? (
                <span className="sr-only"> limit reached</span>
              ) : null}
            </span>
            {c.hint && (
              <span className="hidden tracking-normal normal-case sm:block">
                {c.hint}
              </span>
            )}
          </div>
        ))}
        <div aria-hidden="true" />

        {people.length === 0 && (
          <p className="col-span-full border-t py-3.5 text-sm text-muted-foreground">
            {emptyMessage}
          </p>
        )}

        {people.map((person) => (
          <div key={person.email} className="group contents">
            <div className="flex min-h-12 min-w-0 items-center gap-2.5 border-t py-2">
              <span
                className="grid size-7 flex-none place-items-center rounded-full bg-muted text-xs font-semibold uppercase"
                aria-hidden="true"
              >
                {person.email[0]}
              </span>
              <span className="truncate text-sm" title={person.email}>
                {person.email}
              </span>
            </div>
            {channels.map((c) => {
              const on = Boolean(person.channels[c.key])
              const full =
                Boolean(c.limit) && !on && count(c.key) >= (c.limit as number)
              return (
                <div
                  key={c.key}
                  className="flex min-h-12 items-center justify-center border-t"
                >
                  <Switch
                    aria-label={
                      c.switchLabel?.(person.email) ??
                      `${c.label}: ${person.email}`
                    }
                    aria-describedby={full ? `${limitId}-${c.key}` : undefined}
                    className="relative after:absolute after:-inset-x-1 after:-inset-y-1.5 after:content-['']"
                    checked={on}
                    disabled={disabled || full}
                    onCheckedChange={(checked) =>
                      toggle(person.email, c.key, checked)
                    }
                  />
                </div>
              )
            })}
            <div className="flex min-h-12 items-center justify-center border-t">
              <Button
                variant="ghost"
                size="icon"
                aria-label={`Remove ${person.email}`}
                data-roster-remove=""
                disabled={disabled}
                onClick={() => {
                  removed.current = {
                    index: people.indexOf(person),
                    email: person.email,
                  }
                  setStatus(`Removed ${person.email}.`)
                  onChange(people.filter((p) => p.email !== person.email))
                }}
                className="size-8 text-muted-foreground hover:text-foreground sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100 [@media(hover:none)]:opacity-100"
              >
                <X className="size-4" aria-hidden="true" />
              </Button>
            </div>
          </div>
        ))}
      </div>

      <form
        noValidate
        className="flex items-center gap-2.5 border-t pt-2"
        onSubmit={(event) => {
          event.preventDefault()
          addEmails(draft)
        }}
      >
        <span
          className="grid size-7 flex-none place-items-center rounded-full border border-dashed text-muted-foreground"
          aria-hidden="true"
        >
          <Plus className="size-3.5" />
        </span>
        <Input
          ref={inputRef}
          type="email"
          inputMode="email"
          autoComplete="off"
          aria-label="Add an email address"
          aria-invalid={error ? true : undefined}
          aria-describedby={error ? errorId : undefined}
          placeholder="Add an email address and press Enter"
          value={draft}
          disabled={disabled}
          onChange={(event) => setDraft(event.target.value)}
          // Typing an address and going straight for Save must not lose it.
          onBlur={() => addEmails(draft)}
          onPaste={(event) => {
            const text = event.clipboardData.getData("text")
            if (/[\s,;]/.test(text.trim())) {
              event.preventDefault()
              addEmails(text)
            }
          }}
          className="-ml-2 h-11 border-0 bg-transparent px-2 shadow-none focus-visible:ring-2 focus-visible:ring-ring"
        />
      </form>
      {error && (
        <p id={errorId} role="alert" className="mt-1 text-sm text-destructive">
          {error}
        </p>
      )}
      <p role="status" className="sr-only">
        {status}
      </p>
      {footnote && (
        <p className="mt-3 flex items-center gap-2 text-sm text-muted-foreground">
          <span
            className="size-1.5 flex-none rounded-full bg-muted-foreground/50"
            aria-hidden="true"
          />
          {footnote}
        </p>
      )}
    </div>
  )
}

export { RecipientRoster }
```

**Step 2.** Add the shadcn `button`, `input` and `switch`, and install `lucide-react`.
Update the import paths to match your project setup.

## Usage

```tsx
import { peopleFromLists } from "@/lib/notify-envelope"
import {
  RecipientRoster,
  type RosterChannel,
  type RosterPerson,
} from "@/components/ui/recipient-roster"
```

```tsx
const channels: RosterChannel[] = [
  {
    key: "automatic",
    label: "Each finish",
    hint: "as staff complete",
    limit: 5,
  },
  { key: "onDemand", label: "Digest", hint: "only when sent", limit: 5 },
]

const [people, setPeople] = React.useState<RosterPerson[]>(() =>
  peopleFromLists(saved, ["automatic", "onDemand"])
)
```

```tsx
<RecipientRoster
  people={people}
  channels={channels}
  onChange={setPeople}
  footnote="No one is set, so these notices go to the admin team instead."
/>
```

The roster is controlled. It never saves anything. It calls `onChange` with the next list and you keep the state, so you decide when a change is saved. Pair it with [`save-bar`](https://realgood.site/docs/components/save-bar.md).

## Notes

- **Adding people.** The input at the bottom adds on Enter, and also when it loses focus, so an address typed just before clicking Save is not lost. Pasting text that contains spaces, commas or semicolons adds every address in it.
- **Addresses are lowercased and trimmed.** Duplicates, including ones already on the roster, are skipped. New people start with every channel off.
- **Validation is all or nothing.** If any address in a paste fails `validate`, none of them are added and the message names the first bad one. The default check is deliberately close to what servers accept. Pass `validate` to match your backend exactly. It receives the lowercased address.
- **Limits.** When a channel is at its `limit`, the switches for people who are not on it lock. Switches that are on stay usable, so you can always turn one off.
- **Pass lowercase addresses in `people`.** Duplicate detection compares exact strings. Build the list with `peopleFromLists`, which lowercases, instead of writing it by hand.
- **A channel missing from `person.channels` counts as off.**
- **Layout.** Each channel is a column of 3.5rem on narrow screens and 6.75rem from `sm` up. The `hint` is hidden on narrow screens. The remove button shows on hover on wide screens, and always on touch devices.
- **Accessibility.** Each switch is named "`label`: `email`" unless you pass `switchLabel`. Validation errors are announced with `role="alert"`.

## What it does not do

- **It never saves or sends.** It calls `onChange` with the next list. You keep the state and decide when to save.
- **Contacts are email addresses only.** There are no phone numbers and no SMS or text channels. A channel here is a way an email is sent, such as as things happen or in a digest, not another kind of contact.
- **It does not check that an address exists.** `validate` checks the shape of an address, not that anyone reads it.
- **It does not record consent.** A switch is a setting in your list. It is not proof that a person agreed, and opt-out handling is yours.
- **It does not control access or make anything compliant.** It is UI cues, not compliance.

## Props

### RecipientRoster

Also accepts the props of a `div` (except `onChange`, which is the roster's own), including `ref`. They are applied to the root element. The prop types are exported as `RecipientRosterProps`.

| Prop           | Type                               | Description                                                                        |
| -------------- | ---------------------------------- | ---------------------------------------------------------------------------------- |
| `people`       | `RosterPerson[]`                   | The current list, including unsaved edits. Required.                               |
| `channels`     | `RosterChannel[]`                  | One column and one switch per entry. Required.                                     |
| `onChange`     | `(people: RosterPerson[]) => void` | Called with the full next list after an add, remove or toggle. Required.           |
| `disabled`     | `boolean`                          | Disables the switches, the remove buttons and the input, for example while saving. |
| `validate`     | `(email: string) => boolean`       | Return true if the address is acceptable. Defaults to a server-like email check.   |
| `emptyMessage` | `ReactNode`                        | Shown when `people` is empty. Has a default that tells people to add an address.   |
| `footnote`     | `ReactNode`                        | One line under the list, for example what happens when nobody is set.              |
| `className`    | `string`                           | Merged onto the root element.                                                      |

### RosterChannel

| Field         | Type                        | Description                                                               |
| ------------- | --------------------------- | ------------------------------------------------------------------------- |
| `key`         | `string`                    | Matches a key in each person's `channels`.                                |
| `label`       | `string`                    | Column heading.                                                           |
| `hint`        | `string`                    | When it sends, for example "as staff complete". Hidden on narrow screens. |
| `limit`       | `number`                    | Maximum people on this channel. The heading shows `n / limit`.            |
| `switchLabel` | `(email: string) => string` | Accessible name for a person's switch.                                    |

### RosterPerson

| Field      | Type                      | Description                               |
| ---------- | ------------------------- | ----------------------------------------- |
| `email`    | `string`                  | The person's address, lowercase.          |
| `channels` | `Record<string, boolean>` | Whether they are on each channel, by key. |

## Agent prompt

Paste this into your coding agent (Claude Code, Cursor, Codex or similar) in your project. Replace the bracketed parts with your own.

```text
Goal: add one list of people to my [screen], where each person appears once with a switch per way of hearing about [what, e.g. training completion]. Contacts are email addresses only.

Install:
npx shadcn@latest add https://realgood.site/r/recipient-roster.json
npx shadcn@latest add https://realgood.site/r/notify-envelope.json
Read the installed files (components/ui/recipient-roster.tsx, lib/notify-envelope.ts) before writing any code. Do not guess props.

Contract:
type RosterPerson = { email: string /* lowercase */; channels: Record<string, boolean> }
type RosterChannel = { key: string; label: string; hint?: string; limit?: number; switchLabel?: (email: string) => string }
<RecipientRoster people channels onChange={(people) => void} disabled? validate?={(email) => boolean} emptyMessage? footnote? />
lib/notify-envelope: peopleFromLists(lists, channelKeys), listForChannel(people, key), sameList(a, b)

Wiring rule: the roster is controlled and never saves. Keep `people` in state, built with peopleFromLists from my saved lists (it lowercases). Save one list per channel, made with listForChannel, to my own API, and update the saved lists only after the write succeeds. Pass `validate` to match my backend's email rule. Pair it with save-bar so changes are saved on purpose.

Contacts are email addresses only. Do not add phone numbers, SMS or texting, and do not add a phone field or another channel type.

States to handle: empty list (emptyMessage), a channel at its limit (switches for people not on it lock), an invalid or duplicate address (error shown, nothing added), saving (disabled), save failed.

Acceptance checks (run them and show me the output):
1. Typecheck and lint pass.
2. Pasting "a@x.org, b@x.org bad" adds nobody and names the bad address.
3. A channel at its limit locks the other switches, and the ones that are on can still be turned off.
4. Duplicates are skipped and addresses are stored in lowercase.
5. Keyboard only: Tab reaches each switch, and each is named "<label>: <email>".

UI only: it does not send, store, record consent or control access, and it makes no compliance claim. No new dependencies or abstractions beyond this. If something is unclear, ask me.
```

## Examples by sector

Example data only, to show the wording. These are not claims about any real organisation, and the component proves nothing about a sector's rules. The pattern is the same in each; only the data changes.

- **Education.** Office staff and department leads who hear when staff finish training: one switch for each finish, one for a weekly digest.
- **Manufacturing.** Supervisors and quality engineers who hear about each overdue calibration as it happens, and a plant manager who gets only the digest.
- **Engineering.** Reviewers and the document controller on a change order: one switch for the approval notice, one for a weekly summary.
- **Health.** Practice manager and credentialing staff who hear when a staff licence is close to lapsing. Staff email addresses only, and no patient details in what is sent.

## Next

Comes after: [`status-strip`](https://realgood.site/docs/components/status-strip.md), [`data-table`](https://realgood.site/docs/components/data-table.md). Leads to: [`alert-rules`](https://realgood.site/docs/components/alert-rules.md), [`confirm-send`](https://realgood.site/docs/components/confirm-send.md).
