# Users Invite

An invite form that collects several email addresses as chips, assigns a role, and lists pending invitations.

Users Invite is an open-source block from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/blocks/users-invite

**React**

**Example**

```tsx title="components/blocks/users-invite.tsx"
"use client"

import {
  ChartBarIcon,
  EnvelopeSimpleIcon,
  GearIcon,
  ShieldIcon,
  UsersIcon,
  XIcon,
} from "@phosphor-icons/react"
import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemMedia,
  ItemSeparator,
  ItemTitle,
} from "@/components/ui/item"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInset,
  SidebarMenu,
  SidebarMenuBadge,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
  SidebarRail,
  SidebarTrigger,
  useSidebar,
} from "@/components/ui/sidebar"
import { Switch } from "@/components/ui/switch"
import { createToastManager, Toaster } from "@/components/ui/toast"

const EMAIL = /.+@.+\..+/
const TRAILING_SEPARATOR = /[, ]$/
const TRAILING_COMMA = /,$/

type NavItem = {
  value: string
  label: string
  icon: React.ReactNode
  badge?: React.ReactNode
  group?: string
}

function NavSidebar({
  items,
  value: valueProp,
  defaultValue,
  onValueChange,
  header,
  footer,
}: {
  items: NavItem[]
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  header?: React.ReactNode
  footer?: React.ReactNode
}) {
  const { isMobile, setOpenMobile } = useSidebar()
  const [uncontrolledValue, setUncontrolledValue] = React.useState(
    defaultValue ?? items[0]?.value
  )
  const value = valueProp ?? uncontrolledValue
  const groups = [...new Set(items.map((item) => item.group ?? ""))]

  return (
    <Sidebar collapsible="icon">
      {header && (
        <SidebarHeader className="group-data-[collapsible=icon]:hidden">
          {header}
        </SidebarHeader>
      )}
      <SidebarContent>
        {groups.map((group) => (
          <SidebarGroup key={group}>
            {group && <SidebarGroupLabel>{group}</SidebarGroupLabel>}
            <SidebarGroupContent>
              <SidebarMenu>
                {items
                  .filter((item) => (item.group ?? "") === group)
                  .map((item) => (
                    <SidebarMenuItem key={item.value}>
                      <SidebarMenuButton
                        isActive={item.value === value}
                        tooltip={item.label}
                        aria-current={item.value === value ? "page" : undefined}
                        onClick={() => {
                          if (valueProp === undefined) {
                            setUncontrolledValue(item.value)
                          }
                          onValueChange?.(item.value)
                          if (isMobile) setOpenMobile(false)
                        }}
                      >
                        {item.icon}
                        <span>{item.label}</span>
                      </SidebarMenuButton>
                      {item.badge !== undefined && (
                        <SidebarMenuBadge>{item.badge}</SidebarMenuBadge>
                      )}
                    </SidebarMenuItem>
                  ))}
              </SidebarMenu>
            </SidebarGroupContent>
          </SidebarGroup>
        ))}
      </SidebarContent>
      {footer && (
        <SidebarFooter className="group-data-[collapsible=icon]:hidden">
          {footer}
        </SidebarFooter>
      )}
      <SidebarRail />
    </Sidebar>
  )
}

const nav: NavItem[] = [
  {
    value: "users",
    label: "Users",
    icon: (
      <UsersIcon />
    ),
  },
  {
    value: "roles",
    label: "Roles",
    icon: (
      <ShieldIcon />
    ),
  },
  {
    value: "log",
    label: "Activity",
    icon: (
      <ChartBarIcon />
    ),
  },
  {
    value: "settings",
    label: "Settings",
    icon: (
      <GearIcon />
    ),
  },
]

const roles = ["Admin", "Editor", "Support", "Viewer"]

const toasts = createToastManager()

export function UsersInvite() {
  const [emails, setEmails] = React.useState(["avery@example.com"])
  const [draft, setDraft] = React.useState("")
  const [invalid, setInvalid] = React.useState(false)
  const id = React.useId()
  const [role, setRole] = React.useState("Editor")
  const [welcome, setWelcome] = React.useState(true)
  const [pending, setPending] = React.useState([
    { email: "casey@example.com", role: "Support", sent: "2 days ago" },
    { email: "drew@example.com", role: "Viewer", sent: "5 days ago" },
  ])

  let summary = "Type an email and press Enter."
  if (emails.length)
    summary = `${emails.length} ${emails.length === 1 ? "person" : "people"} will be invited as ${role}.`
  if (invalid) summary = `${draft} is not a valid email address.`

  const commit = (value: string) => {
    const email = value.trim().replace(TRAILING_COMMA, "")
    if (email && !EMAIL.test(email)) {
      setDraft(email)
      setInvalid(true)
      return
    }
    if (email && !emails.includes(email))
      setEmails((current) => [...current, email])
    setDraft("")
    setInvalid(false)
  }

  return (
    <Toaster toastManager={toasts}>
      <SidebarProvider>
        <NavSidebar
          items={nav}
          defaultValue="users"
          header={
            <p className="flex items-center gap-2 px-2 pt-1 text-sm font-semibold">
              <span className="font-bold">suiss</span> Admin
            </p>
          }
        />
        <SidebarInset>
          <div className="flex flex-col gap-5 px-5 py-5.5 md:px-7">
            <div className="flex items-start gap-3">
              <SidebarTrigger className="-ms-2 mt-0.5" />
              <div className="flex flex-col">
                <h1 className="text-3xl font-semibold tracking-tight">
                  Invite users
                </h1>
                <p className="text-sm text-muted-foreground">
                  Invitees get a link by email.
                </p>
              </div>
            </div>
            <ItemGroup variant="inset">
              <Item size="sm" role="listitem" className="items-start">
                <ItemContent className="w-22.5 flex-none pt-1">
                  <ItemTitle className="font-normal">
                    <label htmlFor={`${id}-email`}>Email</label>
                  </ItemTitle>
                </ItemContent>
                <div className="flex min-h-7.5 flex-1 flex-wrap items-center gap-1.5">
                  {emails.map((email) => (
                    <span
                      key={email}
                      className="inline-flex h-6.5 items-center gap-1 rounded-md bg-secondary ps-2.5 pe-0.5 text-sm"
                    >
                      {email}
                      <Button
                        variant="ghost"
                        size="icon-xs"
                        aria-label={`Remove ${email}`}
                        onClick={() =>
                          setEmails((current) =>
                            current.filter((item) => item !== email)
                          )
                        }
                        className="size-5.5 text-muted-foreground"
                      >
                        <XIcon className="size-2.5" />
                      </Button>
                    </span>
                  ))}
                  <input
                    id={`${id}-email`}
                    type="email"
                    value={draft}
                    aria-invalid={invalid || undefined}
                    onChange={(event) => {
                      const value = event.target.value
                      if (TRAILING_SEPARATOR.test(value)) commit(value)
                      else {
                        setDraft(value)
                        setInvalid(false)
                      }
                    }}
                    onKeyDown={(event) => {
                      if (event.key === "Enter") {
                        event.preventDefault()
                        commit(draft)
                      }
                      if (event.key === "Backspace" && !draft)
                        setEmails((current) => current.slice(0, -1))
                    }}
                    onBlur={() => draft && commit(draft)}
                    placeholder={
                      emails.length ? "Add another" : "name@example.com"
                    }
                    className="min-w-35 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
                  />
                </div>
              </Item>
              <ItemSeparator />
              <Item size="sm" role="listitem">
                <ItemContent>
                  <ItemTitle className="font-normal">Role</ItemTitle>
                </ItemContent>
                <ItemActions>
                  <Select
                    value={role}
                    onValueChange={(value) => setRole(value as string)}
                  >
                    <SelectTrigger
                      size="sm"
                      aria-label="Role"
                      className="h-7.5"
                    >
                      <SelectValue />
                    </SelectTrigger>
                    <SelectContent align="end">
                      {roles.map((item) => (
                        <SelectItem key={item} value={item}>
                          {item}
                        </SelectItem>
                      ))}
                    </SelectContent>
                  </Select>
                </ItemActions>
              </Item>
              <ItemSeparator />
              <Item size="sm" role="listitem">
                <ItemContent>
                  <ItemTitle className="font-normal">
                    <label htmlFor={`${id}-welcome`}>Send welcome email</label>
                  </ItemTitle>
                </ItemContent>
                <ItemActions>
                  <Switch
                    id={`${id}-welcome`}
                    checked={welcome}
                    onCheckedChange={setWelcome}
                  />
                </ItemActions>
              </Item>
            </ItemGroup>
            <div className="flex items-center gap-2.5">
              <span
                className="flex-1 text-sm text-muted-foreground"
                aria-live="polite"
              >
                {summary}
              </span>
              <Button
                size="sm"
                disabled={!emails.length}
                onClick={() => {
                  setPending((current) => [
                    ...emails.map((email) => ({
                      email,
                      role,
                      sent: "Just now",
                    })),
                    ...current.filter((item) => !emails.includes(item.email)),
                  ])
                  toasts.add({
                    title: `${emails.length} ${emails.length === 1 ? "invitation" : "invitations"} sent`,
                    type: "success",
                  })
                  setEmails([])
                }}
              >
                Send Invites
              </Button>
            </div>
            <h2 className="-mb-2 px-1 text-sm font-semibold">
              Pending invitations
            </h2>
            {pending.length > 0 ? (
              <ItemGroup
                variant="inset"
                className="**:data-[slot=item-separator]:ms-14"
              >
                {pending.map((invite, index) => (
                  <React.Fragment key={invite.email}>
                    {index > 0 && <ItemSeparator />}
                    <Item
                      size="sm"
                      role="listitem"
                      className="transition-[opacity,translate] duration-700 starting:translate-y-1.5 starting:opacity-0 motion-reduce:transition-none"
                    >
                      <ItemMedia className="size-7 rounded-[7px] bg-zinc-500 text-white">
                        <EnvelopeSimpleIcon className="size-4" />
                      </ItemMedia>
                      <ItemContent className="gap-0">
                        <ItemTitle className="font-normal">
                          {invite.email}
                        </ItemTitle>
                        <ItemDescription className="text-xs">
                          {invite.role} · {invite.sent}
                        </ItemDescription>
                      </ItemContent>
                      <ItemActions>
                        <Button
                          size="sm"
                          variant="secondary"
                          onClick={() =>
                            toasts.add({
                              title: `Resent to ${invite.email}`,
                              type: "success",
                            })
                          }
                        >
                          Resend
                        </Button>
                        <Button
                          size="sm"
                          variant="destructive"
                          className="text-[color-mix(in_oklab,var(--destructive),var(--foreground)_20%)] dark:text-[color-mix(in_oklab,var(--destructive),white_20%)]"
                          onClick={() =>
                            setPending((current) =>
                              current.filter(
                                (item) => item.email !== invite.email
                              )
                            )
                          }
                        >
                          Cancel
                        </Button>
                      </ItemActions>
                    </Item>
                  </React.Fragment>
                ))}
              </ItemGroup>
            ) : (
              <p className="px-1 text-sm text-muted-foreground">
                No pending invitations.
              </p>
            )}
          </div>
        </SidebarInset>
      </SidebarProvider>
    </Toaster>
  )
}
```

An invite form that turns typed email addresses into removable chips, assigns a role and optionally sends a welcome email, with pending invitations listed below. Sending shows a toast and moves the addresses to the pending list, where each can be resent or cancelled. It sits in a `Sidebar` with admin navigation that opens as a sheet from the trigger on mobile. Use it for team invites or sharing access with several people at once.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/users-invite
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add blocks users-invite
```

### Manual

1. Install the dependencies:

```bash title="Terminal"
npm install @phosphor-icons/react
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Item](https://ui.suiss.app/docs/components/item), [Select](https://ui.suiss.app/docs/components/select), [Sidebar](https://ui.suiss.app/docs/components/sidebar), [Switch](https://ui.suiss.app/docs/components/switch), [Toast](https://ui.suiss.app/docs/components/toast).

3. Copy the source into `components/blocks/users-invite.tsx`:

```tsx title="components/blocks/users-invite.tsx"
"use client"

import {
  ChartBarIcon,
  EnvelopeSimpleIcon,
  GearIcon,
  ShieldIcon,
  UsersIcon,
  XIcon,
} from "@phosphor-icons/react"
import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemMedia,
  ItemSeparator,
  ItemTitle,
} from "@/components/ui/item"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInset,
  SidebarMenu,
  SidebarMenuBadge,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
  SidebarRail,
  SidebarTrigger,
  useSidebar,
} from "@/components/ui/sidebar"
import { Switch } from "@/components/ui/switch"
import { createToastManager, Toaster } from "@/components/ui/toast"

const EMAIL = /.+@.+\..+/
const TRAILING_SEPARATOR = /[, ]$/
const TRAILING_COMMA = /,$/

type NavItem = {
  value: string
  label: string
  icon: React.ReactNode
  badge?: React.ReactNode
  group?: string
}

function NavSidebar({
  items,
  value: valueProp,
  defaultValue,
  onValueChange,
  header,
  footer,
}: {
  items: NavItem[]
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  header?: React.ReactNode
  footer?: React.ReactNode
}) {
  const { isMobile, setOpenMobile } = useSidebar()
  const [uncontrolledValue, setUncontrolledValue] = React.useState(
    defaultValue ?? items[0]?.value
  )
  const value = valueProp ?? uncontrolledValue
  const groups = [...new Set(items.map((item) => item.group ?? ""))]

  return (
    <Sidebar collapsible="icon">
      {header && (
        <SidebarHeader className="group-data-[collapsible=icon]:hidden">
          {header}
        </SidebarHeader>
      )}
      <SidebarContent>
        {groups.map((group) => (
          <SidebarGroup key={group}>
            {group && <SidebarGroupLabel>{group}</SidebarGroupLabel>}
            <SidebarGroupContent>
              <SidebarMenu>
                {items
                  .filter((item) => (item.group ?? "") === group)
                  .map((item) => (
                    <SidebarMenuItem key={item.value}>
                      <SidebarMenuButton
                        isActive={item.value === value}
                        tooltip={item.label}
                        aria-current={item.value === value ? "page" : undefined}
                        onClick={() => {
                          if (valueProp === undefined) {
                            setUncontrolledValue(item.value)
                          }
                          onValueChange?.(item.value)
                          if (isMobile) setOpenMobile(false)
                        }}
                      >
                        {item.icon}
                        <span>{item.label}</span>
                      </SidebarMenuButton>
                      {item.badge !== undefined && (
                        <SidebarMenuBadge>{item.badge}</SidebarMenuBadge>
                      )}
                    </SidebarMenuItem>
                  ))}
              </SidebarMenu>
            </SidebarGroupContent>
          </SidebarGroup>
        ))}
      </SidebarContent>
      {footer && (
        <SidebarFooter className="group-data-[collapsible=icon]:hidden">
          {footer}
        </SidebarFooter>
      )}
      <SidebarRail />
    </Sidebar>
  )
}

const nav: NavItem[] = [
  {
    value: "users",
    label: "Users",
    icon: (
      <UsersIcon />
    ),
  },
  {
    value: "roles",
    label: "Roles",
    icon: (
      <ShieldIcon />
    ),
  },
  {
    value: "log",
    label: "Activity",
    icon: (
      <ChartBarIcon />
    ),
  },
  {
    value: "settings",
    label: "Settings",
    icon: (
      <GearIcon />
    ),
  },
]

const roles = ["Admin", "Editor", "Support", "Viewer"]

const toasts = createToastManager()

export function UsersInvite() {
  const [emails, setEmails] = React.useState(["avery@example.com"])
  const [draft, setDraft] = React.useState("")
  const [invalid, setInvalid] = React.useState(false)
  const id = React.useId()
  const [role, setRole] = React.useState("Editor")
  const [welcome, setWelcome] = React.useState(true)
  const [pending, setPending] = React.useState([
    { email: "casey@example.com", role: "Support", sent: "2 days ago" },
    { email: "drew@example.com", role: "Viewer", sent: "5 days ago" },
  ])

  let summary = "Type an email and press Enter."
  if (emails.length)
    summary = `${emails.length} ${emails.length === 1 ? "person" : "people"} will be invited as ${role}.`
  if (invalid) summary = `${draft} is not a valid email address.`

  const commit = (value: string) => {
    const email = value.trim().replace(TRAILING_COMMA, "")
    if (email && !EMAIL.test(email)) {
      setDraft(email)
      setInvalid(true)
      return
    }
    if (email && !emails.includes(email))
      setEmails((current) => [...current, email])
    setDraft("")
    setInvalid(false)
  }

  return (
    <Toaster toastManager={toasts}>
      <SidebarProvider>
        <NavSidebar
          items={nav}
          defaultValue="users"
          header={
            <p className="flex items-center gap-2 px-2 pt-1 text-sm font-semibold">
              <span className="font-bold">suiss</span> Admin
            </p>
          }
        />
        <SidebarInset>
          <div className="flex flex-col gap-5 px-5 py-5.5 md:px-7">
            <div className="flex items-start gap-3">
              <SidebarTrigger className="-ms-2 mt-0.5" />
              <div className="flex flex-col">
                <h1 className="text-3xl font-semibold tracking-tight">
                  Invite users
                </h1>
                <p className="text-sm text-muted-foreground">
                  Invitees get a link by email.
                </p>
              </div>
            </div>
            <ItemGroup variant="inset">
              <Item size="sm" role="listitem" className="items-start">
                <ItemContent className="w-22.5 flex-none pt-1">
                  <ItemTitle className="font-normal">
                    <label htmlFor={`${id}-email`}>Email</label>
                  </ItemTitle>
                </ItemContent>
                <div className="flex min-h-7.5 flex-1 flex-wrap items-center gap-1.5">
                  {emails.map((email) => (
                    <span
                      key={email}
                      className="inline-flex h-6.5 items-center gap-1 rounded-md bg-secondary ps-2.5 pe-0.5 text-sm"
                    >
                      {email}
                      <Button
                        variant="ghost"
                        size="icon-xs"
                        aria-label={`Remove ${email}`}
                        onClick={() =>
                          setEmails((current) =>
                            current.filter((item) => item !== email)
                          )
                        }
                        className="size-5.5 text-muted-foreground"
                      >
                        <XIcon className="size-2.5" />
                      </Button>
                    </span>
                  ))}
                  <input
                    id={`${id}-email`}
                    type="email"
                    value={draft}
                    aria-invalid={invalid || undefined}
                    onChange={(event) => {
                      const value = event.target.value
                      if (TRAILING_SEPARATOR.test(value)) commit(value)
                      else {
                        setDraft(value)
                        setInvalid(false)
                      }
                    }}
                    onKeyDown={(event) => {
                      if (event.key === "Enter") {
                        event.preventDefault()
                        commit(draft)
                      }
                      if (event.key === "Backspace" && !draft)
                        setEmails((current) => current.slice(0, -1))
                    }}
                    onBlur={() => draft && commit(draft)}
                    placeholder={
                      emails.length ? "Add another" : "name@example.com"
                    }
                    className="min-w-35 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
                  />
                </div>
              </Item>
              <ItemSeparator />
              <Item size="sm" role="listitem">
                <ItemContent>
                  <ItemTitle className="font-normal">Role</ItemTitle>
                </ItemContent>
                <ItemActions>
                  <Select
                    value={role}
                    onValueChange={(value) => setRole(value as string)}
                  >
                    <SelectTrigger
                      size="sm"
                      aria-label="Role"
                      className="h-7.5"
                    >
                      <SelectValue />
                    </SelectTrigger>
                    <SelectContent align="end">
                      {roles.map((item) => (
                        <SelectItem key={item} value={item}>
                          {item}
                        </SelectItem>
                      ))}
                    </SelectContent>
                  </Select>
                </ItemActions>
              </Item>
              <ItemSeparator />
              <Item size="sm" role="listitem">
                <ItemContent>
                  <ItemTitle className="font-normal">
                    <label htmlFor={`${id}-welcome`}>Send welcome email</label>
                  </ItemTitle>
                </ItemContent>
                <ItemActions>
                  <Switch
                    id={`${id}-welcome`}
                    checked={welcome}
                    onCheckedChange={setWelcome}
                  />
                </ItemActions>
              </Item>
            </ItemGroup>
            <div className="flex items-center gap-2.5">
              <span
                className="flex-1 text-sm text-muted-foreground"
                aria-live="polite"
              >
                {summary}
              </span>
              <Button
                size="sm"
                disabled={!emails.length}
                onClick={() => {
                  setPending((current) => [
                    ...emails.map((email) => ({
                      email,
                      role,
                      sent: "Just now",
                    })),
                    ...current.filter((item) => !emails.includes(item.email)),
                  ])
                  toasts.add({
                    title: `${emails.length} ${emails.length === 1 ? "invitation" : "invitations"} sent`,
                    type: "success",
                  })
                  setEmails([])
                }}
              >
                Send Invites
              </Button>
            </div>
            <h2 className="-mb-2 px-1 text-sm font-semibold">
              Pending invitations
            </h2>
            {pending.length > 0 ? (
              <ItemGroup
                variant="inset"
                className="**:data-[slot=item-separator]:ms-14"
              >
                {pending.map((invite, index) => (
                  <React.Fragment key={invite.email}>
                    {index > 0 && <ItemSeparator />}
                    <Item
                      size="sm"
                      role="listitem"
                      className="transition-[opacity,translate] duration-700 starting:translate-y-1.5 starting:opacity-0 motion-reduce:transition-none"
                    >
                      <ItemMedia className="size-7 rounded-[7px] bg-zinc-500 text-white">
                        <EnvelopeSimpleIcon className="size-4" />
                      </ItemMedia>
                      <ItemContent className="gap-0">
                        <ItemTitle className="font-normal">
                          {invite.email}
                        </ItemTitle>
                        <ItemDescription className="text-xs">
                          {invite.role} · {invite.sent}
                        </ItemDescription>
                      </ItemContent>
                      <ItemActions>
                        <Button
                          size="sm"
                          variant="secondary"
                          onClick={() =>
                            toasts.add({
                              title: `Resent to ${invite.email}`,
                              type: "success",
                            })
                          }
                        >
                          Resend
                        </Button>
                        <Button
                          size="sm"
                          variant="destructive"
                          className="text-[color-mix(in_oklab,var(--destructive),var(--foreground)_20%)] dark:text-[color-mix(in_oklab,var(--destructive),white_20%)]"
                          onClick={() =>
                            setPending((current) =>
                              current.filter(
                                (item) => item.email !== invite.email
                              )
                            )
                          }
                        >
                          Cancel
                        </Button>
                      </ItemActions>
                    </Item>
                  </React.Fragment>
                ))}
              </ItemGroup>
            ) : (
              <p className="px-1 text-sm text-muted-foreground">
                No pending invitations.
              </p>
            )}
          </div>
        </SidebarInset>
      </SidebarProvider>
    </Toaster>
  )
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { UsersInvite } from "@/components/blocks/users-invite"
```

```tsx
<UsersInvite />
```

Replace the initial `pending` list with invitations from your API, and edit `roles`. Addresses are committed on Enter, comma, space or blur, and only valid, unique ones are added. Call your invitation API in place of the local send, resend and cancel handlers.

## Notes

Each chip's remove button names its address, and the summary line is announced as chips change.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/blocks-users-invite--default.light.webp)

```tsx title="uniwind/components/blocks/users-invite.tsx"
import {
  ChartBarIcon,
  EnvelopeSimpleIcon,
  GearIcon,
  type Icon as PhosphorIcon,
  ShieldIcon,
  UsersIcon,
} from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { FadeInDown, ReduceMotion } from "react-native-reanimated"
import { Picker, PickerItem } from "@/components/patterns/picker"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Chip } from "@/components/ui/chip"
import { HeaderLargeTitle } from "@/components/ui/header"
import { Icon } from "@/components/ui/icon"
import { Input } from "@/components/ui/input"
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemMedia,
  ItemSeparator,
  ItemTitle,
} from "@/components/ui/item"
import { KeyboardScrollView } from "@/components/ui/keyboard-scroll-view"
import { Screen } from "@/components/ui/screen"
import { Switch } from "@/components/ui/switch"
import { TabBar, TabBarItem, TabBarList } from "@/components/ui/tab-bar"
import { Text } from "@/components/ui/text"
import { createToastManager, Toaster } from "@/components/ui/toast"

const EMAIL = /.+@.+\..+/
const TRAILING_SEPARATOR = /[, ]$/
const TRAILING_COMMA = /,$/

const SECTIONS: { value: string; label: string; icon: PhosphorIcon }[] = [
  { value: "users", label: "Users", icon: UsersIcon },
  { value: "roles", label: "Roles", icon: ShieldIcon },
  { value: "log", label: "Activity", icon: ChartBarIcon },
  { value: "settings", label: "Settings", icon: GearIcon },
]

const ROLES = ["Admin", "Editor", "Support", "Viewer"]

const toasts = createToastManager()

type Invite = { email: string; role: string; sent: string }

function AdminTabBar({
  value,
  onValueChange,
}: {
  value: string
  onValueChange: (value: string) => void
}) {
  return (
    <TabBar value={value} onValueChange={(next) => onValueChange(next)}>
      <TabBarList>
        {SECTIONS.map((section) => (
          <TabBarItem
            key={section.value}
            value={section.value}
            icon={section.icon}
          >
            {section.label}
          </TabBarItem>
        ))}
      </TabBarList>
    </TabBar>
  )
}

function OtherSection({ value, home }: { value: string; home: string }) {
  const label = SECTIONS.find((section) => section.value === value)?.label
  return (
    <View className="flex-1 gap-2 px-4 pt-13">
      <Text role="heading" className="text-3xl font-bold">
        {label}
      </Text>
      <Text className="text-lg text-muted-foreground">
        {`This section isn't part of the sample. Choose ${home}.`}
      </Text>
    </View>
  )
}

function InviteForm({
  onInvite,
  onResend,
  onCancelInvite,
}: {
  onInvite?: (emails: string[], role: string, welcome: boolean) => void
  onResend?: (email: string) => void
  onCancelInvite?: (email: string) => void
}) {
  const [emails, setEmails] = React.useState(["avery@example.com"])
  const [draft, setDraft] = React.useState("")
  const [role, setRole] = React.useState("Editor")
  const [welcome, setWelcome] = React.useState(true)
  const [pending, setPending] = React.useState<Invite[]>([
    { email: "casey@example.com", role: "Support", sent: "2 days ago" },
    { email: "drew@example.com", role: "Viewer", sent: "5 days ago" },
  ])

  const commit = (value: string) => {
    const email = value.trim().replace(TRAILING_COMMA, "")
    if (EMAIL.test(email) && !emails.includes(email))
      setEmails((current) => [...current, email])
    setDraft("")
  }

  const send = () => {
    onInvite?.(emails, role, welcome)
    setPending((current) => [
      ...emails.map((email) => ({ email, role, sent: "Just now" })),
      ...current,
    ])
    toasts.add({
      title: `${emails.length} ${emails.length === 1 ? "invitation" : "invitations"} sent`,
      type: "success",
    })
    setEmails([])
  }

  return (
    <KeyboardScrollView contentContainerClassName="gap-5 pt-3 pb-8">
      <HeaderLargeTitle subtitle="Invitees get a link by email.">
        Invite users
      </HeaderLargeTitle>
      <View className="gap-5 px-4">
        <ItemGroup variant="inset">
          <Item size="sm" className="items-start">
            <ItemContent className="w-22.5 flex-none pt-1.5">
              <ItemTitle nativeID="users-invite-email" className="font-normal">
                Email
              </ItemTitle>
            </ItemContent>
            <View className="min-h-7.5 min-w-0 flex-1 flex-row flex-wrap items-center gap-1.5">
              {emails.map((email) => (
                <Chip
                  key={email}
                  size="sm"
                  selected={false}
                  onRemove={() =>
                    setEmails((current) =>
                      current.filter((item) => item !== email)
                    )
                  }
                  removeLabel={`Remove ${email}`}
                >
                  {email}
                </Chip>
              ))}
              <Input
                value={draft}
                onChangeText={(value) => {
                  if (TRAILING_SEPARATOR.test(value)) commit(value)
                  else setDraft(value)
                }}
                onKeyPress={(event) => {
                  if (event.nativeEvent.key === "Backspace" && !draft)
                    setEmails((current) => current.slice(0, -1))
                }}
                onSubmitEditing={() => commit(draft)}
                onBlur={() => {
                  if (draft) commit(draft)
                }}
                submitBehavior="submit"
                keyboardType="email-address"
                autoCapitalize="none"
                autoCorrect={false}
                autoComplete="email"
                placeholder={emails.length ? "Add another" : "name@example.com"}
                accessibilityLabelledBy="users-invite-email"
                className="h-7.5 min-w-35 flex-1 border-0 bg-transparent px-0 text-sm"
              />
            </View>
          </Item>
          <ItemSeparator />
          <Item size="sm">
            <ItemContent>
              <ItemTitle className="font-normal">Role</ItemTitle>
            </ItemContent>
            <ItemActions>
              <Picker
                size="sm"
                value={role}
                onValueChange={setRole}
                accessibilityLabel="Role"
                className="bg-transparent px-1"
              >
                {ROLES.map((item) => (
                  <PickerItem key={item} value={item}>
                    {item}
                  </PickerItem>
                ))}
              </Picker>
            </ItemActions>
          </Item>
          <ItemSeparator />
          <Item size="sm">
            <ItemContent>
              <ItemTitle
                nativeID="users-invite-welcome"
                className="font-normal"
              >
                Send welcome email
              </ItemTitle>
            </ItemContent>
            <ItemActions>
              <Switch
                checked={welcome}
                onCheckedChange={setWelcome}
                accessibilityLabelledBy="users-invite-welcome"
              />
            </ItemActions>
          </Item>
        </ItemGroup>
        <View className="flex-row items-center gap-2.5">
          <Text
            accessibilityLiveRegion="polite"
            className="flex-1 text-sm text-muted-foreground"
          >
            {emails.length
              ? `${emails.length} ${emails.length === 1 ? "person" : "people"} will be invited as ${role}.`
              : "Type an email and press Enter."}
          </Text>
          <Button size="sm" disabled={!emails.length} onPress={send}>
            <Text>Send Invites</Text>
          </Button>
        </View>
        <Text role="heading" className="-mb-2 px-1 text-sm font-semibold">
          Pending invitations
        </Text>
        {pending.length > 0 ? (
          <ItemGroup variant="inset">
            {pending.map((invite, index) => (
              <AnimatedView
                key={invite.email}
                entering={FadeInDown.duration(700).reduceMotion(
                  ReduceMotion.System
                )}
              >
                {index > 0 ? <ItemSeparator className="ms-14" /> : null}
                <Item size="sm">
                  <ItemMedia className="size-7 rounded-[7px] bg-zinc-500">
                    <Icon
                      as={EnvelopeSimpleIcon}
                      weight="bold"
                      className="size-4 text-primary-foreground"
                    />
                  </ItemMedia>
                  <ItemContent className="gap-0">
                    <ItemTitle className="text-sm font-normal">
                      {invite.email}
                    </ItemTitle>
                    <ItemDescription className="text-xs">
                      {`${invite.role} · ${invite.sent}`}
                    </ItemDescription>
                  </ItemContent>
                  <ItemActions>
                    <Button
                      size="sm"
                      variant="secondary"
                      onPress={() => {
                        onResend?.(invite.email)
                        toasts.add({
                          title: `Resent to ${invite.email}`,
                          type: "success",
                        })
                      }}
                    >
                      <Text>Resend</Text>
                    </Button>
                    <Button
                      size="sm"
                      variant="destructive"
                      onPress={() => {
                        onCancelInvite?.(invite.email)
                        setPending((current) =>
                          current.filter((item) => item.email !== invite.email)
                        )
                      }}
                    >
                      <Text>Cancel</Text>
                    </Button>
                  </ItemActions>
                </Item>
              </AnimatedView>
            ))}
          </ItemGroup>
        ) : (
          <Text className="px-1 text-sm text-muted-foreground">
            No pending invitations.
          </Text>
        )}
      </View>
    </KeyboardScrollView>
  )
}

function UsersInvite({
  onInvite,
  onResend,
  onCancelInvite,
  onNavigate,
}: {
  onInvite?: (emails: string[], role: string, welcome: boolean) => void
  onResend?: (email: string) => void
  onCancelInvite?: (email: string) => void
  onNavigate?: (section: string) => void
}) {
  const [section, setSection] = React.useState("users")

  return (
    <Toaster toastManager={toasts}>
      <Screen edges={["top", "right", "left"]}>
        {section === "users" ? (
          <InviteForm
            onInvite={onInvite}
            onResend={onResend}
            onCancelInvite={onCancelInvite}
          />
        ) : (
          <OtherSection value={section} home="Users" />
        )}
        <AdminTabBar
          value={section}
          onValueChange={(value) => {
            setSection(value)
            onNavigate?.(value)
          }}
        />
      </Screen>
    </Toaster>
  )
}

export { UsersInvite }
```

```swift title="swift/Blocks/UsersInvite.swift"
import PhosphorSwift
import SwiftUI

public struct UsersInvite: View {
  struct Invite: Identifiable, Equatable {
    let email: String
    let role: String
    let sent: String

    var id: String { email }
  }

  static let roles = ["Admin", "Editor", "Support", "Viewer"]

  static var initialPending: [Invite] {
    [
      Invite(email: "casey@example.com", role: "Support", sent: "2 days ago"),
      Invite(email: "drew@example.com", role: "Viewer", sent: "5 days ago"),
    ]
  }

  static func email(_ value: String) -> String? {
    var email = value.trimmingCharacters(in: .whitespaces)
    if email.hasSuffix(",") { email.removeLast() }
    return email.wholeMatch(of: /.+@.+\..+/) != nil ? email : nil
  }

  private var onNavigate: ((String) -> Void)?
  private var onInvite: (([String], String, Bool) -> Void)?
  private var onResend: ((String) -> Void)?
  private var onCancelInvite: ((String) -> Void)?
  @State private var emails: [String]
  @State private var draft: String
  @State private var role: String?
  @State private var welcome = true
  @State private var pending: [Invite]
  @State private var toasts = ToastManager()
  @FocusState private var focused: Bool
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(
    onInvite: ((_ emails: [String], _ role: String, _ welcome: Bool) -> Void)? = nil,
    onResend: ((_ email: String) -> Void)? = nil,
    onCancelInvite: ((_ email: String) -> Void)? = nil,
    onNavigate: ((_ section: String) -> Void)? = nil
  ) {
    self.init(emails: ["avery@example.com"])
    self.onNavigate = onNavigate
    self.onInvite = onInvite
    self.onResend = onResend
    self.onCancelInvite = onCancelInvite
  }

  init(emails: [String], draft: String = "", role: String = "Editor", pending: [Invite]? = nil) {
    _emails = State(initialValue: emails)
    _draft = State(initialValue: draft)
    _role = State(initialValue: role)
    _pending = State(initialValue: pending ?? Self.initialPending)
  }

  private var animation: Animation? {
    reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.4)
  }

  public var body: some View {
    UsersInviteShell(home: "users", onNavigate: onNavigate) {
      page
    }
  }

  @ViewBuilder private var page: some View {
    let current = role ?? "Editor"
    Screen {
      HeaderScrollView {
        HeaderLargeTitle("Invite users", subtitle: "Invitees get a link by email.")
        VStack(alignment: .leading, spacing: 20) {
          ItemGroup(.inset) {
            emailRow
            ItemSeparator()
            Item(size: .sm) {
              ItemContent {
                ItemTitle { Text("Role").fontWeight(.regular) }
              }
              ItemActions {
                OptionPicker(selection: $role, size: .sm) {
                  ForEach(Self.roles, id: \.self) { item in
                    OptionPickerItem(LocalizedStringKey(item), value: item)
                  }
                }
                .fixedSize()
                .accessibilityLabel(Text("Role"))
              }
            }
            ItemSeparator()
            Item(size: .sm) {
              ItemContent {
                ItemTitle { Text("Send welcome email").fontWeight(.regular) }
              }
              ItemActions {
                Toggle(isOn: $welcome) { Text("Send welcome email") }
                  .labelsHidden()
                  .toggleStyle(SwitchStyle())
                  .accessibilityLabel(Text("Send welcome email"))
              }
            }
          }
          HStack(spacing: 10) {
            Text(emails.isEmpty ? "Type an email and press Return." : emails.count == 1 ? "1 person will be invited as \(current)." : "\(emails.count) people will be invited as \(current).")
              .scaledFont(.sm)
              .foregroundStyle(EsuissColor.mutedForeground)
              .fixedSize(horizontal: false, vertical: true)
              .frame(maxWidth: .infinity, alignment: .leading)
              .contentTransition(.opacity)
            Button("Send Invites") {
              send(role: current)
            }
            .buttonStyle(.variant(.default, size: .sm))
            .disabled(emails.isEmpty)
          }
          .padding(.horizontal, 4)
          VStack(alignment: .leading, spacing: 8) {
            Text("Pending invitations")
              .scaledFont(.sm, weight: .semibold)
              .padding(.horizontal, 4)
              .accessibilityAddTraits(.isHeader)
            if pending.isEmpty {
              Text("No pending invitations.")
                .scaledFont(.sm)
                .foregroundStyle(EsuissColor.mutedForeground)
                .padding(.horizontal, 4)
            } else {
              ItemGroup(.inset) {
                ForEach(Array(pending.enumerated()), id: \.element.id) { index, invite in
                  if index > 0 {
                    ItemSeparator().padding(.leading, 40)
                  }
                  pendingRow(invite)
                    .transition(.opacity.combined(with: .offset(y: 6)))
                }
              }
            }
          }
        }
        .padding(.horizontal, 16)
        .padding(.top, 8)
        .padding(.bottom, 24)
      } header: {
        Header {
          HeaderTitle("Invite users")
        }
      }
    }
    .toaster(toasts)
  }

  private var emailRow: some View {
    HStack(alignment: .top, spacing: 12) {
      Text("Email")
        .scaledFont(.base)
        .frame(width: 70, alignment: .leading)
        .padding(.top, 5)
        .accessibilityHidden(true)
      ComboboxFlowLayout(spacing: 6) {
        ForEach(emails, id: \.self) { email in
          Chip(size: .sm, onRemove: {
            withAnimation(animation) { emails.removeAll { $0 == email } }
          }) {
            Text(verbatim: email)
          }
          .transition(.scale(scale: 0.8).combined(with: .opacity))
        }
        TextField(
          "Email",
          text: $draft,
          prompt: (emails.isEmpty ? Text(verbatim: "name@example.com") : Text("Add another")).foregroundStyle(EsuissColor.mutedForeground)
        )
          .scaledFont(.sm)
          .keyboardType(.emailAddress)
          .textContentType(.emailAddress)
          .textInputAutocapitalization(.never)
          .autocorrectionDisabled()
          .submitLabel(.next)
          .focused($focused)
          .frame(minWidth: 140, minHeight: 28)
          .onSubmit {
            commit(draft)
            focused = true
          }
          .onChange(of: draft) { _, value in
            if value.hasSuffix(",") || value.hasSuffix(" ") { commit(value) }
          }
          .onChange(of: focused) { _, value in
            if !value && !draft.isEmpty { commit(draft) }
          }
          .accessibilityLabel(Text("Email"))
      }
      .frame(maxWidth: .infinity, alignment: .leading)
    }
    .foregroundStyle(EsuissColor.foreground)
    .padding(.horizontal, 16)
    .padding(.vertical, 8)
    .frame(minHeight: 44)
  }

  private func pendingRow(_ invite: Invite) -> some View {
    HStack(spacing: 12) {
      Ph.envelopeSimple.bold
        .frame(width: 16, height: 16)
        .foregroundStyle(EsuissColor.primaryForeground)
        .frame(width: 28, height: 28)
        .background(Palette.zinc500, in: RoundedRectangle(cornerRadius: 7, style: .continuous))
        .accessibilityHidden(true)
      VStack(alignment: .leading, spacing: 1) {
        Text(verbatim: invite.email)
          .scaledFont(.sm)
          .lineLimit(1)
          .truncationMode(.middle)
        Text(verbatim: "\(invite.role) · \(invite.sent)")
          .scaledFont(.xs)
          .foregroundStyle(EsuissColor.mutedForeground)
      }
      .frame(maxWidth: .infinity, alignment: .leading)
      .accessibilityElement(children: .combine)
      Button("Resend") {
        onResend?(invite.email)
        toasts.success("Resent to \(invite.email)")
      }
      .buttonStyle(.variant(.secondary, size: .xs))
      Button("Cancel") {
        onCancelInvite?(invite.email)
        withAnimation(animation) { pending.removeAll { $0.email == invite.email } }
      }
      .buttonStyle(.variant(.destructive, size: .xs))
      .accessibilityLabel(Text("Cancel invitation to \(invite.email)"))
    }
    .foregroundStyle(EsuissColor.foreground)
    .padding(.horizontal, 16)
    .padding(.vertical, 10)
    .frame(minHeight: 44)
  }

  private func commit(_ value: String) {
    if let email = Self.email(value), !emails.contains(email) {
      withAnimation(animation) { emails.append(email) }
    }
    draft = ""
  }

  private func send(role: String) {
    if !draft.isEmpty { commit(draft) }
    guard !emails.isEmpty else { return }
    onInvite?(emails, role, welcome)
    let count = emails.count
    withAnimation(animation) {
      pending.removeAll { emails.contains($0.email) }
      pending.insert(contentsOf: emails.map { Invite(email: $0, role: role, sent: "Just now") }, at: 0)
      emails = []
    }
    toasts.success(count == 1 ? "1 invitation sent" : "\(count) invitations sent")
  }
}

private struct UsersInviteShell<Content: View>: View {
  let home: String
  let onNavigate: ((String) -> Void)?
  let content: Content

  @State private var tab: String

  init(home: String, onNavigate: ((String) -> Void)?, @ViewBuilder content: () -> Content) {
    self.home = home
    self.onNavigate = onNavigate
    self.content = content()
    _tab = State(initialValue: home)
  }

  private static var sections: [(value: String, label: LocalizedStringKey, icon: Image, active: Image)] {
    [
      ("users", "Users", Ph.users.regular, Ph.users.fill),
      ("roles", "Roles", Ph.shield.regular, Ph.shield.fill),
      ("log", "Activity", Ph.chartBar.regular, Ph.chartBar.fill),
      ("settings", "Settings", Ph.gear.regular, Ph.gear.fill),
    ]
  }

  var body: some View {
    let selection = Binding<String>(
      get: { tab },
      set: { value in
        guard value != tab else { return }
        onNavigate?(value)
        tab = value
      }
    )
    Screen {
      ZStack {
        content
          .opacity(tab == home ? 1 : 0)
          .allowsHitTesting(tab == home)
          .accessibilityHidden(tab != home)
        if tab != home {
          Empty {
            EmptyHeader {
              EmptyDescription("This section isn't part of the sample. Choose \(Text(Self.sections.first { $0.value == home }?.label ?? "")).")
            }
          }
          .background(EsuissColor.background)
        }
      }
      .frame(maxHeight: .infinity)
      TabBar(selection: selection) {
        ForEach(Self.sections, id: \.value) { section in
          TabBarItem(section.label, value: section.value, icon: section.icon, activeIcon: section.active)
        }
      }
    }
  }
}
```

```kt title="kotlin/blocks/UsersInvite.kt"
package com.esuiss.ui.blocks

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Bold
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.bold.EnvelopeSimple
import com.adamglin.phosphoricons.fill.ChartBar as FillChartBar
import com.adamglin.phosphoricons.fill.Gear as FillGear
import com.adamglin.phosphoricons.fill.Shield as FillShield
import com.adamglin.phosphoricons.fill.Users as FillUsers
import com.adamglin.phosphoricons.regular.ChartBar
import com.adamglin.phosphoricons.regular.Gear
import com.adamglin.phosphoricons.regular.Shield
import com.adamglin.phosphoricons.regular.Users
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Chip
import com.esuiss.ui.components.ChipSize
import com.esuiss.ui.components.Header
import com.esuiss.ui.components.HeaderLargeTitle
import com.esuiss.ui.components.HeaderTitle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Item
import com.esuiss.ui.components.ItemActions
import com.esuiss.ui.components.ItemContent
import com.esuiss.ui.components.ItemGroup
import com.esuiss.ui.components.ItemGroupVariant
import com.esuiss.ui.components.ItemMedia
import com.esuiss.ui.components.ItemSeparator
import com.esuiss.ui.components.ItemSize
import com.esuiss.ui.components.ItemTitle
import com.esuiss.ui.components.KeyboardScrollView
import com.esuiss.ui.components.Screen
import com.esuiss.ui.components.ScreenEdge
import com.esuiss.ui.components.SelectSize
import com.esuiss.ui.components.Switch
import com.esuiss.ui.components.TabBar
import com.esuiss.ui.components.TabBarItem
import com.esuiss.ui.components.Toaster
import com.esuiss.ui.components.rememberHeaderState
import com.esuiss.ui.components.rememberToastState
import com.esuiss.ui.patterns.Picker
import com.esuiss.ui.patterns.PickerItemData
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Typography

private val InviteRevealEasing = CubicBezierEasing(0.32f, 0.72f, 0f, 1f)

private val InviteEmail = Regex(".+@.+\\..+")

private val InviteRoles = listOf("Admin", "Editor", "Support", "Viewer").map { PickerItemData(it, it) }

private class PendingInvite(val email: String, val role: String, val sent: String)

@Composable
fun UsersInvite(
    modifier: Modifier = Modifier,
    onInvite: (emails: List<String>, role: String, welcome: Boolean) -> Unit = { _, _, _ -> },
    onResend: (email: String) -> Unit = {},
    onCancelInvite: (email: String) -> Unit = {},
    onNavigate: (section: String) -> Unit = {},
) {
    val colors = EsuissTheme.colors
    val header = rememberHeaderState()
    val toasts = rememberToastState()
    val emails = remember { mutableStateListOf("avery@example.com") }
    var draft by rememberSaveable { mutableStateOf("") }
    var role by rememberSaveable { mutableStateOf("Editor") }
    var welcome by rememberSaveable { mutableStateOf(true) }
    val pending = remember {
        mutableStateListOf(
            PendingInvite("casey@example.com", "Support", "2 days ago"),
            PendingInvite("drew@example.com", "Viewer", "5 days ago"),
        )
    }

    fun commit(value: String) {
        val email = value.trim().trimEnd(',').trim()
        if (InviteEmail.matches(email) && email !in emails) emails += email
        draft = ""
    }

    fun send() {
        if (emails.isEmpty()) return
        val sent = emails.toList()
        onInvite(sent, role, welcome)
        pending.addAll(0, sent.map { PendingInvite(it, role, "Just now") })
        toasts.success("${sent.size} ${if (sent.size == 1) "invitation" else "invitations"} sent")
        emails.clear()
    }

    Screen(modifier, edges = setOf(ScreenEdge.Top, ScreenEdge.Left, ScreenEdge.Right)) {
        AdminTabs("users", onNavigate) {
            Toaster(toasts) {
                Column(
                    Modifier
                        .fillMaxSize()
                        .nestedScroll(header.nestedScrollConnection),
                ) {
                    Header(state = header) {
                        HeaderTitle("Invite users")
                        HeaderLargeTitle("Invite users", subtitle = "Invitees get a link by email.")
                    }
                    KeyboardScrollView(
                        modifier = Modifier.weight(1f),
                        contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 32.dp),
                        verticalArrangement = Arrangement.spacedBy(20.dp),
                    ) {
                        ItemGroup(variant = ItemGroupVariant.Inset) {
                            Row(
                                modifier = Modifier
                                    .fillMaxWidth()
                                    .padding(horizontal = 14.dp, vertical = 10.dp),
                                horizontalArrangement = Arrangement.spacedBy(12.dp),
                            ) {
                                BasicText(
                                    "Email",
                                    modifier = Modifier
                                        .width(90.dp)
                                        .padding(top = 5.dp),
                                    style = Typography.base.copy(color = colors.foreground),
                                )
                                FlowRow(
                                    modifier = Modifier
                                        .weight(1f)
                                        .heightIn(min = 30.dp),
                                    horizontalArrangement = Arrangement.spacedBy(6.dp),
                                    verticalArrangement = Arrangement.spacedBy(6.dp),
                                    itemVerticalAlignment = Alignment.CenterVertically,
                                ) {
                                    emails.forEach { email ->
                                        key(email) {
                                            Chip(
                                                text = email,
                                                selected = false,
                                                onSelectedChange = {},
                                                size = ChipSize.Sm,
                                                onRemove = { emails.remove(email) },
                                                removeLabel = "Remove $email",
                                            )
                                        }
                                    }
                                    InviteField(
                                        value = draft,
                                        placeholder = if (emails.isNotEmpty()) "Add another" else "name@example.com",
                                        onValueChange = { value ->
                                            if (value.endsWith(",") || value.endsWith(" ")) commit(value) else draft = value
                                        },
                                        onCommit = { if (draft.isNotBlank()) commit(draft) },
                                    )
                                }
                            }
                            ItemSeparator()
                            InviteRow("Role") {
                                Picker(
                                    value = role,
                                    onValueChange = { role = it },
                                    modifier = Modifier.semantics { contentDescription = "Role" },
                                    items = InviteRoles,
                                    size = SelectSize.Sm,
                                )
                            }
                            ItemSeparator()
                            InviteRow("Send welcome email") {
                                Switch(
                                    checked = welcome,
                                    onCheckedChange = { welcome = it },
                                    modifier = Modifier.semantics { contentDescription = "Send welcome email" },
                                )
                            }
                        }
                        Row(
                            modifier = Modifier.padding(horizontal = 4.dp),
                            horizontalArrangement = Arrangement.spacedBy(10.dp),
                            verticalAlignment = Alignment.CenterVertically,
                        ) {
                            BasicText(
                                if (emails.isNotEmpty()) {
                                    "${emails.size} ${if (emails.size == 1) "person" else "people"} will be invited as $role."
                                } else {
                                    "Type an email and press Done."
                                },
                                modifier = Modifier
                                    .weight(1f)
                                    .semantics { liveRegion = LiveRegionMode.Polite },
                                style = Typography.sm.copy(color = colors.mutedForeground),
                            )
                            Button(text = "Send Invites", onClick = ::send, size = ButtonSize.Sm, enabled = emails.isNotEmpty())
                        }
                        Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
                            BasicText(
                                "Pending invitations",
                                modifier = Modifier
                                    .padding(horizontal = 4.dp)
                                    .semantics { heading() },
                                style = Typography.sm.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
                            )
                            if (pending.isEmpty()) {
                                BasicText(
                                    "No pending invitations.",
                                    modifier = Modifier.padding(horizontal = 4.dp),
                                    style = Typography.sm.copy(color = colors.mutedForeground),
                                )
                            } else {
                                ItemGroup(variant = ItemGroupVariant.Inset) {
                                    pending.forEachIndexed { index, invite ->
                                        key(invite.email) {
                                            if (index > 0) ItemSeparator(Modifier.padding(start = 38.dp))
                                            PendingRow(
                                                invite = invite,
                                                onResend = {
                                                    onResend(invite.email)
                                                    toasts.success("Resent to ${invite.email}")
                                                },
                                                onCancel = {
                                                    onCancelInvite(invite.email)
                                                    pending.remove(invite)
                                                },
                                            )
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

@Composable
private fun InviteField(
    value: String,
    placeholder: String,
    onValueChange: (String) -> Unit,
    onCommit: () -> Unit,
) {
    val colors = EsuissTheme.colors
    val style = Typography.sm.copy(color = colors.foreground)
    BasicTextField(
        value = value,
        onValueChange = onValueChange,
        modifier = Modifier
            .widthIn(min = 140.dp)
            .padding(vertical = 5.dp)
            .onFocusChanged { if (!it.isFocused) onCommit() }
            .semantics { contentDescription = "Email" },
        textStyle = style,
        singleLine = true,
        cursorBrush = SolidColor(colors.primary),
        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Done),
        keyboardActions = KeyboardActions(onDone = { onCommit() }),
        decorationBox = { inner ->
            Box {
                if (value.isEmpty()) BasicText(placeholder, style = style.copy(color = colors.mutedForeground), maxLines = 1)
                inner()
            }
        },
    )
}

@Composable
private fun InviteRow(title: String, action: @Composable () -> Unit) {
    Item(size = ItemSize.Sm, role = null) {
        ItemContent {
            ItemTitle {
                BasicText(
                    title,
                    style = Typography.base.copy(color = EsuissTheme.colors.foreground),
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis,
                )
            }
        }
        ItemActions { action() }
    }
}

@Composable
private fun PendingRow(invite: PendingInvite, onResend: () -> Unit, onCancel: () -> Unit) {
    val colors = EsuissTheme.colors
    val appear = remember { Animatable(0f) }
    LaunchedEffect(Unit) { appear.animateTo(1f, tween(700, easing = InviteRevealEasing)) }
    Item(
        modifier = Modifier.graphicsLayer {
            alpha = appear.value
            translationY = (1f - appear.value) * 6.dp.toPx()
        },
        size = ItemSize.Sm,
        role = null,
    ) {
        ItemMedia {
            Box(
                Modifier
                    .size(28.dp)
                    .background(Palette.zinc500, RoundedCornerShape(7.dp)),
                contentAlignment = Alignment.Center,
            ) {
                Icon(PhosphorIcons.Bold.EnvelopeSimple, contentDescription = null, size = 16.dp, tint = Palette.white)
            }
        }
        ItemContent {
            BasicText(
                invite.email,
                style = Typography.sm.copy(color = colors.foreground),
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
            BasicText("${invite.role} · ${invite.sent}", style = Typography.xs.copy(color = colors.mutedForeground))
        }
        ItemActions {
            Button(text = "Resend", onClick = onResend, size = ButtonSize.Xs, variant = ButtonVariant.Secondary)
            Button(text = "Cancel", onClick = onCancel, size = ButtonSize.Xs, variant = ButtonVariant.Destructive)
        }
    }
}

private val AdminSections = listOf("users" to "Users", "roles" to "Roles", "log" to "Activity", "settings" to "Settings")

@Composable
private fun AdminTabs(home: String, onNavigate: (String) -> Unit, content: @Composable () -> Unit) {
    val colors = EsuissTheme.colors
    var tab by rememberSaveable { mutableStateOf(home) }
    Column(Modifier.fillMaxSize()) {
        Box(
            Modifier
                .weight(1f)
                .fillMaxWidth(),
        ) {
            if (tab == home) {
                content()
            } else {
                BasicText(
                    "This section isn't part of the sample. Choose ${AdminSections.first { it.first == home }.second}.",
                    modifier = Modifier
                        .align(Alignment.Center)
                        .padding(32.dp),
                    style = Typography.base.copy(color = colors.mutedForeground, textAlign = TextAlign.Center),
                )
            }
        }
        TabBar(
            value = tab,
            onValueChange = {
                onNavigate(it)
                tab = it
            },
        ) {
            TabBarItem("users", "Users", PhosphorIcons.Regular.Users, selectedIcon = PhosphorIcons.Fill.FillUsers)
            TabBarItem("roles", "Roles", PhosphorIcons.Regular.Shield, selectedIcon = PhosphorIcons.Fill.FillShield)
            TabBarItem("log", "Activity", PhosphorIcons.Regular.ChartBar, selectedIcon = PhosphorIcons.Fill.FillChartBar)
            TabBarItem("settings", "Settings", PhosphorIcons.Regular.Gear, selectedIcon = PhosphorIcons.Fill.FillGear)
        }
    }
}
```

Emails become `Chip`s as you type, and Backspace in an empty `Input` removes the last one. A `Picker` sets the role, a `Switch` toggles the welcome email, and the send `Button` adds the invites to the pending list. Pending invitations can be resent or cancelled, and sending or resending shows a toast through `Toaster`. The admin `TabBar` switches sections.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/users-invite
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add users-invite
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add users-invite --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add users-invite --platform kotlin
```

The CLI also adds what it builds on: [Button](https://ui.suiss.app/docs/components/button), [Chip](https://ui.suiss.app/docs/components/chip), [Header](https://ui.suiss.app/docs/components/header), [Icon](https://ui.suiss.app/docs/components/icon), [Input](https://ui.suiss.app/docs/components/input), [Item](https://ui.suiss.app/docs/components/item), [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view), [Picker](https://ui.suiss.app/docs/patterns/picker), [Screen](https://ui.suiss.app/docs/components/screen), [Switch](https://ui.suiss.app/docs/components/switch), [Tab Bar](https://ui.suiss.app/docs/components/tab-bar), [Text](https://ui.suiss.app/docs/components/text), [Toast](https://ui.suiss.app/docs/components/toast). On React Native it also adds the animated view `components/ui/animated-view.tsx`.

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Chip](https://ui.suiss.app/docs/components/chip), [Header](https://ui.suiss.app/docs/components/header), [Icon](https://ui.suiss.app/docs/components/icon), [Input](https://ui.suiss.app/docs/components/input), [Item](https://ui.suiss.app/docs/components/item), [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view), [Picker](https://ui.suiss.app/docs/patterns/picker), [Screen](https://ui.suiss.app/docs/components/screen), [Switch](https://ui.suiss.app/docs/components/switch), [Tab Bar](https://ui.suiss.app/docs/components/tab-bar), [Text](https://ui.suiss.app/docs/components/text), [Toast](https://ui.suiss.app/docs/components/toast), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `uniwind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/uniwind/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/users-invite.tsx`:

- `uniwind/components/blocks/users-invite.tsx`: shown above

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Chip](https://ui.suiss.app/docs/components/chip), [Header](https://ui.suiss.app/docs/components/header), [Icon](https://ui.suiss.app/docs/components/icon), [Input](https://ui.suiss.app/docs/components/input), [Item](https://ui.suiss.app/docs/components/item), [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view), [Picker](https://ui.suiss.app/docs/patterns/picker), [Screen](https://ui.suiss.app/docs/components/screen), [Switch](https://ui.suiss.app/docs/components/switch), [Tab Bar](https://ui.suiss.app/docs/components/tab-bar), [Text](https://ui.suiss.app/docs/components/text), [Toast](https://ui.suiss.app/docs/components/toast), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `nativewind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/nativewind/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/users-invite.tsx`:

```tsx title="nativewind/components/blocks/users-invite.tsx"
import {
  ChartBarIcon,
  EnvelopeSimpleIcon,
  GearIcon,
  type Icon as PhosphorIcon,
  ShieldIcon,
  UsersIcon,
} from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { FadeInDown, ReduceMotion } from "react-native-reanimated"
import { Picker, PickerItem } from "@/components/patterns/picker"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Chip } from "@/components/ui/chip"
import { HeaderLargeTitle } from "@/components/ui/header"
import { Icon } from "@/components/ui/icon"
import { Input } from "@/components/ui/input"
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemMedia,
  ItemSeparator,
  ItemTitle,
} from "@/components/ui/item"
import { KeyboardScrollView } from "@/components/ui/keyboard-scroll-view"
import { Screen } from "@/components/ui/screen"
import { Switch } from "@/components/ui/switch"
import { TabBar, TabBarItem, TabBarList } from "@/components/ui/tab-bar"
import { Text } from "@/components/ui/text"
import { createToastManager, Toaster } from "@/components/ui/toast"

const EMAIL = /.+@.+\..+/
const TRAILING_SEPARATOR = /[, ]$/
const TRAILING_COMMA = /,$/

const SECTIONS: { value: string; label: string; icon: PhosphorIcon }[] = [
  { value: "users", label: "Users", icon: UsersIcon },
  { value: "roles", label: "Roles", icon: ShieldIcon },
  { value: "log", label: "Activity", icon: ChartBarIcon },
  { value: "settings", label: "Settings", icon: GearIcon },
]

const ROLES = ["Admin", "Editor", "Support", "Viewer"]

const toasts = createToastManager()

type Invite = { email: string; role: string; sent: string }

function AdminTabBar({
  value,
  onValueChange,
}: {
  value: string
  onValueChange: (value: string) => void
}) {
  return (
    <TabBar value={value} onValueChange={(next) => onValueChange(next)}>
      <TabBarList>
        {SECTIONS.map((section) => (
          <TabBarItem
            key={section.value}
            value={section.value}
            icon={section.icon}
          >
            {section.label}
          </TabBarItem>
        ))}
      </TabBarList>
    </TabBar>
  )
}

function OtherSection({ value, home }: { value: string; home: string }) {
  const label = SECTIONS.find((section) => section.value === value)?.label
  return (
    <View className="flex-1 gap-2 px-4 pt-[3.25rem]">
      <Text role="heading" className="text-3xl font-bold">
        {label}
      </Text>
      <Text className="text-lg text-muted-foreground">
        {`This section isn't part of the sample. Choose ${home}.`}
      </Text>
    </View>
  )
}

function InviteForm({
  onInvite,
  onResend,
  onCancelInvite,
}: {
  onInvite?: (emails: string[], role: string, welcome: boolean) => void
  onResend?: (email: string) => void
  onCancelInvite?: (email: string) => void
}) {
  const [emails, setEmails] = React.useState(["avery@example.com"])
  const [draft, setDraft] = React.useState("")
  const [role, setRole] = React.useState("Editor")
  const [welcome, setWelcome] = React.useState(true)
  const [pending, setPending] = React.useState<Invite[]>([
    { email: "casey@example.com", role: "Support", sent: "2 days ago" },
    { email: "drew@example.com", role: "Viewer", sent: "5 days ago" },
  ])

  const commit = (value: string) => {
    const email = value.trim().replace(TRAILING_COMMA, "")
    if (EMAIL.test(email) && !emails.includes(email))
      setEmails((current) => [...current, email])
    setDraft("")
  }

  const send = () => {
    onInvite?.(emails, role, welcome)
    setPending((current) => [
      ...emails.map((email) => ({ email, role, sent: "Just now" })),
      ...current,
    ])
    toasts.add({
      title: `${emails.length} ${emails.length === 1 ? "invitation" : "invitations"} sent`,
      type: "success",
    })
    setEmails([])
  }

  return (
    <KeyboardScrollView contentContainerClassName="gap-5 pt-3 pb-8">
      <HeaderLargeTitle subtitle="Invitees get a link by email.">
        Invite users
      </HeaderLargeTitle>
      <View className="gap-5 px-4">
        <ItemGroup variant="inset">
          <Item size="sm" className="items-start">
            <ItemContent className="w-[5.625rem] flex-none pt-1.5">
              <ItemTitle nativeID="users-invite-email" className="font-normal">
                Email
              </ItemTitle>
            </ItemContent>
            <View className="min-h-[1.875rem] min-w-0 flex-1 flex-row flex-wrap items-center gap-1.5">
              {emails.map((email) => (
                <Chip
                  key={email}
                  size="sm"
                  selected={false}
                  onRemove={() =>
                    setEmails((current) =>
                      current.filter((item) => item !== email)
                    )
                  }
                  removeLabel={`Remove ${email}`}
                >
                  {email}
                </Chip>
              ))}
              <Input
                value={draft}
                onChangeText={(value) => {
                  if (TRAILING_SEPARATOR.test(value)) commit(value)
                  else setDraft(value)
                }}
                onKeyPress={(event) => {
                  if (event.nativeEvent.key === "Backspace" && !draft)
                    setEmails((current) => current.slice(0, -1))
                }}
                onSubmitEditing={() => commit(draft)}
                onBlur={() => {
                  if (draft) commit(draft)
                }}
                submitBehavior="submit"
                keyboardType="email-address"
                autoCapitalize="none"
                autoCorrect={false}
                autoComplete="email"
                placeholder={emails.length ? "Add another" : "name@example.com"}
                accessibilityLabelledBy="users-invite-email"
                className="h-[1.875rem] min-w-[8.75rem] flex-1 border-0 bg-transparent px-0 text-sm"
              />
            </View>
          </Item>
          <ItemSeparator />
          <Item size="sm">
            <ItemContent>
              <ItemTitle className="font-normal">Role</ItemTitle>
            </ItemContent>
            <ItemActions>
              <Picker
                size="sm"
                value={role}
                onValueChange={setRole}
                accessibilityLabel="Role"
                className="bg-transparent px-1"
              >
                {ROLES.map((item) => (
                  <PickerItem key={item} value={item}>
                    {item}
                  </PickerItem>
                ))}
              </Picker>
            </ItemActions>
          </Item>
          <ItemSeparator />
          <Item size="sm">
            <ItemContent>
              <ItemTitle
                nativeID="users-invite-welcome"
                className="font-normal"
              >
                Send welcome email
              </ItemTitle>
            </ItemContent>
            <ItemActions>
              <Switch
                checked={welcome}
                onCheckedChange={setWelcome}
                accessibilityLabelledBy="users-invite-welcome"
              />
            </ItemActions>
          </Item>
        </ItemGroup>
        <View className="flex-row items-center gap-2.5">
          <Text
            accessibilityLiveRegion="polite"
            className="flex-1 text-sm text-muted-foreground"
          >
            {emails.length
              ? `${emails.length} ${emails.length === 1 ? "person" : "people"} will be invited as ${role}.`
              : "Type an email and press Enter."}
          </Text>
          <Button size="sm" disabled={!emails.length} onPress={send}>
            <Text>Send Invites</Text>
          </Button>
        </View>
        <Text role="heading" className="-mb-2 px-1 text-sm font-semibold">
          Pending invitations
        </Text>
        {pending.length > 0 ? (
          <ItemGroup variant="inset">
            {pending.map((invite, index) => (
              <AnimatedView
                key={invite.email}
                entering={FadeInDown.duration(700).reduceMotion(
                  ReduceMotion.System
                )}
              >
                {index > 0 ? <ItemSeparator className="ms-14" /> : null}
                <Item size="sm">
                  <ItemMedia className="size-7 rounded-[7px] bg-zinc-500">
                    <Icon
                      as={EnvelopeSimpleIcon}
                      weight="bold"
                      className="size-4 text-primary-foreground"
                    />
                  </ItemMedia>
                  <ItemContent className="gap-0">
                    <ItemTitle className="text-sm font-normal">
                      {invite.email}
                    </ItemTitle>
                    <ItemDescription className="text-xs">
                      {`${invite.role} · ${invite.sent}`}
                    </ItemDescription>
                  </ItemContent>
                  <ItemActions>
                    <Button
                      size="sm"
                      variant="secondary"
                      onPress={() => {
                        onResend?.(invite.email)
                        toasts.add({
                          title: `Resent to ${invite.email}`,
                          type: "success",
                        })
                      }}
                    >
                      <Text>Resend</Text>
                    </Button>
                    <Button
                      size="sm"
                      variant="destructive"
                      onPress={() => {
                        onCancelInvite?.(invite.email)
                        setPending((current) =>
                          current.filter((item) => item.email !== invite.email)
                        )
                      }}
                    >
                      <Text>Cancel</Text>
                    </Button>
                  </ItemActions>
                </Item>
              </AnimatedView>
            ))}
          </ItemGroup>
        ) : (
          <Text className="px-1 text-sm text-muted-foreground">
            No pending invitations.
          </Text>
        )}
      </View>
    </KeyboardScrollView>
  )
}

function UsersInvite({
  onInvite,
  onResend,
  onCancelInvite,
  onNavigate,
}: {
  onInvite?: (emails: string[], role: string, welcome: boolean) => void
  onResend?: (email: string) => void
  onCancelInvite?: (email: string) => void
  onNavigate?: (section: string) => void
}) {
  const [section, setSection] = React.useState("users")

  return (
    <Toaster toastManager={toasts}>
      <Screen edges={["top", "right", "left"]}>
        {section === "users" ? (
          <InviteForm
            onInvite={onInvite}
            onResend={onResend}
            onCancelInvite={onCancelInvite}
          />
        ) : (
          <OtherSection value={section} home="Users" />
        )}
        <AdminTabBar
          value={section}
          onValueChange={(value) => {
            setSection(value)
            onNavigate?.(value)
          }}
        />
      </Screen>
    </Toaster>
  )
}

export { UsersInvite }
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Chip](https://ui.suiss.app/docs/components/chip), [Header](https://ui.suiss.app/docs/components/header), [Icon](https://ui.suiss.app/docs/components/icon), [Input](https://ui.suiss.app/docs/components/input), [Item](https://ui.suiss.app/docs/components/item), [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view), [Picker](https://ui.suiss.app/docs/patterns/picker), [Screen](https://ui.suiss.app/docs/components/screen), [Switch](https://ui.suiss.app/docs/components/switch), [Tab Bar](https://ui.suiss.app/docs/components/tab-bar), [Text](https://ui.suiss.app/docs/components/text), [Toast](https://ui.suiss.app/docs/components/toast), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `stylesheet/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/stylesheet/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/users-invite.tsx`:

```tsx title="stylesheet/components/blocks/users-invite.tsx"
import {
  ChartBarIcon,
  EnvelopeSimpleIcon,
  GearIcon,
  type Icon as PhosphorIcon,
  ShieldIcon,
  UsersIcon,
} from "phosphor-react-native"
import * as React from "react"
import { StyleSheet, View } from "react-native"
import { FadeInDown, ReduceMotion } from "react-native-reanimated"
import { Picker, PickerItem } from "@/components/patterns/picker"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Chip } from "@/components/ui/chip"
import { HeaderLargeTitle } from "@/components/ui/header"
import { Icon } from "@/components/ui/icon"
import { Input } from "@/components/ui/input"
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemMedia,
  ItemSeparator,
  ItemTitle,
} from "@/components/ui/item"
import { KeyboardScrollView } from "@/components/ui/keyboard-scroll-view"
import { Screen } from "@/components/ui/screen"
import { Switch } from "@/components/ui/switch"
import { TabBar, TabBarItem, TabBarList } from "@/components/ui/tab-bar"
import { Text } from "@/components/ui/text"
import { fontSize, palette, useTheme } from "@/components/ui/theme"
import { createToastManager, Toaster } from "@/components/ui/toast"

const EMAIL = /.+@.+\..+/
const TRAILING_SEPARATOR = /[, ]$/
const TRAILING_COMMA = /,$/

const SECTIONS: { value: string; label: string; icon: PhosphorIcon }[] = [
  { value: "users", label: "Users", icon: UsersIcon },
  { value: "roles", label: "Roles", icon: ShieldIcon },
  { value: "log", label: "Activity", icon: ChartBarIcon },
  { value: "settings", label: "Settings", icon: GearIcon },
]

const ROLES = ["Admin", "Editor", "Support", "Viewer"]

const toasts = createToastManager()

type Invite = { email: string; role: string; sent: string }

const styles = StyleSheet.create({
  other: { flex: 1, gap: 8, paddingHorizontal: 16, paddingTop: 52 },
  otherTitle: { ...fontSize["3xl"], fontWeight: "700" },
  scroll: { gap: 20, paddingTop: 12, paddingBottom: 32 },
  body: { gap: 20, paddingHorizontal: 16 },
  top: { alignItems: "flex-start" },
  label: { width: 90, flex: 0, paddingTop: 6 },
  regular: { fontWeight: "400" },
  emails: {
    minHeight: 30,
    minWidth: 0,
    flex: 1,
    flexDirection: "row",
    flexWrap: "wrap",
    alignItems: "center",
    gap: 6,
  },
  input: {
    ...fontSize.sm,
    height: 30,
    minWidth: 140,
    flex: 1,
    borderWidth: 0,
    backgroundColor: "transparent",
    paddingHorizontal: 0,
  },
  picker: { backgroundColor: "transparent", paddingHorizontal: 4 },
  footer: { flexDirection: "row", alignItems: "center", gap: 10 },
  summary: { ...fontSize.sm, flex: 1 },
  heading: {
    ...fontSize.sm,
    marginBottom: -8,
    paddingHorizontal: 4,
    fontWeight: "600",
  },
  separator: { marginStart: 56 },
  media: { width: 28, height: 28, borderRadius: 7 },
  tight: { gap: 0 },
  email: { ...fontSize.sm, fontWeight: "400" },
  empty: { ...fontSize.sm, paddingHorizontal: 4 },
})

function AdminTabBar({
  value,
  onValueChange,
}: {
  value: string
  onValueChange: (value: string) => void
}) {
  return (
    <TabBar value={value} onValueChange={(next) => onValueChange(next)}>
      <TabBarList>
        {SECTIONS.map((section) => (
          <TabBarItem
            key={section.value}
            value={section.value}
            icon={section.icon}
          >
            {section.label}
          </TabBarItem>
        ))}
      </TabBarList>
    </TabBar>
  )
}

function OtherSection({ value, home }: { value: string; home: string }) {
  const { colors } = useTheme()
  const label = SECTIONS.find((section) => section.value === value)?.label
  return (
    <View style={styles.other}>
      <Text role="heading" style={styles.otherTitle}>
        {label}
      </Text>
      <Text style={[fontSize.lg, { color: colors.mutedForeground }]}>
        {`This section isn't part of the sample. Choose ${home}.`}
      </Text>
    </View>
  )
}

function InviteForm({
  onInvite,
  onResend,
  onCancelInvite,
}: {
  onInvite?: (emails: string[], role: string, welcome: boolean) => void
  onResend?: (email: string) => void
  onCancelInvite?: (email: string) => void
}) {
  const { colors } = useTheme()
  const [emails, setEmails] = React.useState(["avery@example.com"])
  const [draft, setDraft] = React.useState("")
  const [role, setRole] = React.useState("Editor")
  const [welcome, setWelcome] = React.useState(true)
  const [pending, setPending] = React.useState<Invite[]>([
    { email: "casey@example.com", role: "Support", sent: "2 days ago" },
    { email: "drew@example.com", role: "Viewer", sent: "5 days ago" },
  ])

  const commit = (value: string) => {
    const email = value.trim().replace(TRAILING_COMMA, "")
    if (EMAIL.test(email) && !emails.includes(email))
      setEmails((current) => [...current, email])
    setDraft("")
  }

  const send = () => {
    onInvite?.(emails, role, welcome)
    setPending((current) => [
      ...emails.map((email) => ({ email, role, sent: "Just now" })),
      ...current,
    ])
    toasts.add({
      title: `${emails.length} ${emails.length === 1 ? "invitation" : "invitations"} sent`,
      type: "success",
    })
    setEmails([])
  }

  return (
    <KeyboardScrollView contentContainerStyle={styles.scroll}>
      <HeaderLargeTitle subtitle="Invitees get a link by email.">
        Invite users
      </HeaderLargeTitle>
      <View style={styles.body}>
        <ItemGroup variant="inset">
          <Item size="sm" style={styles.top}>
            <ItemContent style={styles.label}>
              <ItemTitle nativeID="users-invite-email" style={styles.regular}>
                Email
              </ItemTitle>
            </ItemContent>
            <View style={styles.emails}>
              {emails.map((email) => (
                <Chip
                  key={email}
                  size="sm"
                  selected={false}
                  onRemove={() =>
                    setEmails((current) =>
                      current.filter((item) => item !== email)
                    )
                  }
                  removeLabel={`Remove ${email}`}
                >
                  {email}
                </Chip>
              ))}
              <Input
                value={draft}
                onChangeText={(value) => {
                  if (TRAILING_SEPARATOR.test(value)) commit(value)
                  else setDraft(value)
                }}
                onKeyPress={(event) => {
                  if (event.nativeEvent.key === "Backspace" && !draft)
                    setEmails((current) => current.slice(0, -1))
                }}
                onSubmitEditing={() => commit(draft)}
                onBlur={() => {
                  if (draft) commit(draft)
                }}
                submitBehavior="submit"
                keyboardType="email-address"
                autoCapitalize="none"
                autoCorrect={false}
                autoComplete="email"
                placeholder={emails.length ? "Add another" : "name@example.com"}
                accessibilityLabelledBy="users-invite-email"
                style={styles.input}
              />
            </View>
          </Item>
          <ItemSeparator />
          <Item size="sm">
            <ItemContent>
              <ItemTitle style={styles.regular}>Role</ItemTitle>
            </ItemContent>
            <ItemActions>
              <Picker
                size="sm"
                value={role}
                onValueChange={setRole}
                accessibilityLabel="Role"
                style={styles.picker}
              >
                {ROLES.map((item) => (
                  <PickerItem key={item} value={item}>
                    {item}
                  </PickerItem>
                ))}
              </Picker>
            </ItemActions>
          </Item>
          <ItemSeparator />
          <Item size="sm">
            <ItemContent>
              <ItemTitle nativeID="users-invite-welcome" style={styles.regular}>
                Send welcome email
              </ItemTitle>
            </ItemContent>
            <ItemActions>
              <Switch
                checked={welcome}
                onCheckedChange={setWelcome}
                accessibilityLabelledBy="users-invite-welcome"
              />
            </ItemActions>
          </Item>
        </ItemGroup>
        <View style={styles.footer}>
          <Text
            accessibilityLiveRegion="polite"
            style={[styles.summary, { color: colors.mutedForeground }]}
          >
            {emails.length
              ? `${emails.length} ${emails.length === 1 ? "person" : "people"} will be invited as ${role}.`
              : "Type an email and press Enter."}
          </Text>
          <Button size="sm" disabled={!emails.length} onPress={send}>
            <Text>Send Invites</Text>
          </Button>
        </View>
        <Text role="heading" style={styles.heading}>
          Pending invitations
        </Text>
        {pending.length > 0 ? (
          <ItemGroup variant="inset">
            {pending.map((invite, index) => (
              <AnimatedView
                key={invite.email}
                entering={FadeInDown.duration(700).reduceMotion(
                  ReduceMotion.System
                )}
              >
                {index > 0 ? <ItemSeparator style={styles.separator} /> : null}
                <Item size="sm">
                  <ItemMedia
                    style={[styles.media, { backgroundColor: palette.zinc500 }]}
                  >
                    <Icon
                      as={EnvelopeSimpleIcon}
                      weight="bold"
                      size={16}
                      color={colors.primaryForeground}
                    />
                  </ItemMedia>
                  <ItemContent style={styles.tight}>
                    <ItemTitle style={styles.email}>{invite.email}</ItemTitle>
                    <ItemDescription style={fontSize.xs}>
                      {`${invite.role} · ${invite.sent}`}
                    </ItemDescription>
                  </ItemContent>
                  <ItemActions>
                    <Button
                      size="sm"
                      variant="secondary"
                      onPress={() => {
                        onResend?.(invite.email)
                        toasts.add({
                          title: `Resent to ${invite.email}`,
                          type: "success",
                        })
                      }}
                    >
                      <Text>Resend</Text>
                    </Button>
                    <Button
                      size="sm"
                      variant="destructive"
                      onPress={() => {
                        onCancelInvite?.(invite.email)
                        setPending((current) =>
                          current.filter((item) => item.email !== invite.email)
                        )
                      }}
                    >
                      <Text>Cancel</Text>
                    </Button>
                  </ItemActions>
                </Item>
              </AnimatedView>
            ))}
          </ItemGroup>
        ) : (
          <Text style={[styles.empty, { color: colors.mutedForeground }]}>
            No pending invitations.
          </Text>
        )}
      </View>
    </KeyboardScrollView>
  )
}

function UsersInvite({
  onInvite,
  onResend,
  onCancelInvite,
  onNavigate,
}: {
  onInvite?: (emails: string[], role: string, welcome: boolean) => void
  onResend?: (email: string) => void
  onCancelInvite?: (email: string) => void
  onNavigate?: (section: string) => void
}) {
  const [section, setSection] = React.useState("users")

  return (
    <Toaster toastManager={toasts}>
      <Screen edges={["top", "right", "left"]}>
        {section === "users" ? (
          <InviteForm
            onInvite={onInvite}
            onResend={onResend}
            onCancelInvite={onCancelInvite}
          />
        ) : (
          <OtherSection value={section} home="Users" />
        )}
        <AdminTabBar
          value={section}
          onValueChange={(value) => {
            setSection(value)
            onNavigate?.(value)
          }}
        />
      </Screen>
    </Toaster>
  )
}

export { UsersInvite }
```

5. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

- `swift/Blocks/UsersInvite.swift`: shown above

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/Checkbox.swift`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `swift/Components/Chip.swift`: see [Chip](https://ui.suiss.app/docs/components/chip.md)

- `swift/Components/Combobox.swift`: see [Combobox](https://ui.suiss.app/docs/components/combobox.md)

- `swift/Components/DropdownMenu.swift`: see [Dropdown Menu](https://ui.suiss.app/docs/components/dropdown-menu.md)

- `swift/Components/Empty.swift`: see [Empty](https://ui.suiss.app/docs/components/empty.md)

- `swift/Components/Field.swift`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `swift/Components/Haptics.swift`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `swift/Components/Header.swift`: see [Header](https://ui.suiss.app/docs/components/header.md)

- `swift/Components/Item.swift`: see [Item](https://ui.suiss.app/docs/components/item.md)

- `swift/Components/KeyboardScrollView.swift`: see [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view.md)

- `swift/Components/Overlay.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/Overlay.swift)

- `swift/Components/Screen.swift`: see [Screen](https://ui.suiss.app/docs/components/screen.md)

- `swift/Components/Select.swift`: see [Select](https://ui.suiss.app/docs/components/select.md)

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `swift/Components/Switch.swift`: see [Switch](https://ui.suiss.app/docs/components/switch.md)

- `swift/Components/TabBar.swift`: see [Tab Bar](https://ui.suiss.app/docs/components/tab-bar.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

- `swift/Components/Toast.swift`: see [Toast](https://ui.suiss.app/docs/components/toast.md)

- `swift/Patterns/Adaptive.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Patterns/Adaptive.swift)

- `swift/Patterns/OptionPicker.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Patterns/OptionPicker.swift)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

- `kotlin/blocks/UsersInvite.kt`: shown above

- `kotlin/components/Button.kt`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `kotlin/components/ButtonGroup.kt`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `kotlin/components/Checkbox.kt`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `kotlin/components/Chip.kt`: see [Chip](https://ui.suiss.app/docs/components/chip.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Field.kt`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `kotlin/components/FormSupport.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/FormSupport.kt)

- `kotlin/components/Haptics.kt`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `kotlin/components/Header.kt`: see [Header](https://ui.suiss.app/docs/components/header.md)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

- `kotlin/components/Item.kt`: see [Item](https://ui.suiss.app/docs/components/item.md)

- `kotlin/components/KeyboardScrollView.kt`: see [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view.md)

- `kotlin/components/Label.kt`: see [Label](https://ui.suiss.app/docs/components/label.md)

- `kotlin/components/Overlay.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Overlay.kt)

- `kotlin/components/Screen.kt`: see [Screen](https://ui.suiss.app/docs/components/screen.md)

- `kotlin/components/Select.kt`: see [Select](https://ui.suiss.app/docs/components/select.md)

- `kotlin/components/Separator.kt`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `kotlin/components/Switch.kt`: see [Switch](https://ui.suiss.app/docs/components/switch.md)

- `kotlin/components/TabBar.kt`: see [Tab Bar](https://ui.suiss.app/docs/components/tab-bar.md)

- `kotlin/components/Toast.kt`: see [Toast](https://ui.suiss.app/docs/components/toast.md)

- `kotlin/patterns/Picker.kt`: see [Picker](https://ui.suiss.app/docs/patterns/picker.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { UsersInvite } from "@/components/blocks/users-invite"
```

```tsx
<UsersInvite />
```

Replace `ROLES` and the initial pending invites with your data. Use `onInvite`, `onResend`, `onCancelInvite` and `onNavigate` to call your API.

