{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "invite-members",
  "title": "Invite Members",
  "type": "registry:block",
  "files": [
    {
      "path": "packages/registry-angular/blocks/invite-members/invite-members.component.ts",
      "content": "import { ChangeDetectionStrategy, Component, DestroyRef, Input, computed, inject, signal } from '@angular/core'\nimport { LucideAngularModule, RotateCw, Send, UserPlus, X } from 'lucide-angular'\nimport { cn } from '@/lib/utils'\nimport { UiAvatarComponent, UiAvatarFallbackComponent } from '@/ui/avatar/avatar.component'\nimport { UiBadgeComponent } from '@/ui/badge/badge.component'\nimport type { BadgeVariants } from '@/ui/badge/badge.component'\nimport { UiButtonComponent } from '@/ui/button/button.component'\nimport { UiInputComponent } from '@/ui/input/input.component'\nimport { UiSectionCardComponent } from '@/ui/section-card/section-card.component'\nimport {\n  UiSelectComponent,\n  UiSelectContentComponent,\n  UiSelectItemComponent,\n  UiSelectTriggerComponent,\n  UiSelectValueComponent,\n} from '@/ui/select/select.component'\n\nexport type InviteRole = 'admin' | 'editor' | 'viewer'\nexport type InviteStatus = 'pending' | 'accepted' | 'expired'\n\nexport interface InviteEntry {\n  id: string\n  email: string\n  role: InviteRole\n  status: InviteStatus\n  sentAt: Date\n}\n\nconst EMAIL_RE = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\nconst SEATS_PILL =\n  'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium whitespace-nowrap tabular-nums'\nconst CHIP =\n  'border-border bg-muted/40 text-foreground inline-flex max-w-full items-center gap-1 rounded-full border py-0.5 pr-1 pl-2.5 ' +\n  'text-xs font-medium'\nconst CHIP_REMOVE =\n  'hover:bg-background inline-flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-full ' +\n  'transition-colors'\nconst SEATS_PILL_WARN = 'border-warning/30 bg-warning/10 text-warning'\nconst SEATS_PILL_OK = 'border-border bg-muted/40 text-muted-foreground'\n\nconst ROLE_LABEL: Record<InviteRole, string> = { admin: 'Admin', editor: 'Editor', viewer: 'Viewer' }\nconst ROLE_VARIANT: Record<InviteRole, BadgeVariants['variant']> = {\n  admin: 'default',\n  editor: 'info',\n  viewer: 'outline',\n}\nconst STATUS_LABEL: Record<InviteStatus, string> = { pending: 'Pending', accepted: 'Accepted', expired: 'Expired' }\nconst STATUS_VARIANT: Record<InviteStatus, BadgeVariants['variant']> = {\n  pending: 'warning',\n  accepted: 'success',\n  expired: 'secondary',\n}\n\nconst now = Date.now()\nconst minutesAgo = (m: number) => new Date(now - m * 60_000)\n\nconst DEFAULT_INVITES: InviteEntry[] = [\n  { id: 'inv-1', email: 'priya@acme.com', role: 'admin', status: 'pending', sentAt: minutesAgo(120) },\n  { id: 'inv-2', email: 'marcus@acme.com', role: 'editor', status: 'accepted', sentAt: minutesAgo(60 * 24 * 3) },\n  { id: 'inv-3', email: 'devon@acme.com', role: 'viewer', status: 'pending', sentAt: minutesAgo(60 * 26) },\n  { id: 'inv-4', email: 'sarah@acme.com', role: 'editor', status: 'expired', sentAt: minutesAgo(60 * 24 * 12) },\n  { id: 'inv-5', email: 'lena@acme.com', role: 'admin', status: 'accepted', sentAt: minutesAgo(60 * 24 * 7) },\n]\n\nfunction tokenize(text: string): string[] {\n  return text\n    .split(/[\\s,;]+/)\n    .map((token) => token.trim())\n    .filter(Boolean)\n}\n\nfunction invalidMessage(invalid: string[]): string {\n  return invalid.length === 1\n    ? `“${invalid[0]}” is not a valid email address.`\n    : `${invalid.length} entries are not valid email addresses: ${invalid.join(', ')}`\n}\n\n/**\n * InviteMembers -- the React block, 1:1: a self-contained team invite surface in a\n * SectionCard. Header carries a live seats counter tinting warning at >=90% capacity.\n * Compose takes one email or a comma/space-separated paste -- several addresses queue as\n * removable chips first -- plus an Admin/Editor/Viewer role Select and validation with a\n * destructive error state. Rows show avatar initials, email, role/status badges\n * (pending/accepted/expired), sent-ago timestamps, Resend (re-activates expired invites)\n * and Revoke behind a two-click inline confirm. Swap DEFAULT_INVITES for your data source.\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-invite-members, [ui-invite-members]',\n  standalone: true,\n  // React renders the SectionCard itself: the host stays out of layout and `class` goes to the card.\n  host: { '[attr.class]': '\"contents\"' },\n  imports: [\n    LucideAngularModule,\n    UiAvatarComponent,\n    UiAvatarFallbackComponent,\n    UiBadgeComponent,\n    UiButtonComponent,\n    UiInputComponent,\n    UiSectionCardComponent,\n    UiSelectComponent,\n    UiSelectContentComponent,\n    UiSelectItemComponent,\n    UiSelectTriggerComponent,\n    UiSelectValueComponent,\n  ],\n  template: `\n    <div ui-section-card data-slot=\"invite-members\" [title]=\"title\" [description]=\"description\" [class]=\"className\">\n      <span slot=\"header-action\" data-slot=\"invite-members-seats\" [class]=\"seatsPillClass()\">\n        {{ seatsUsed() }} of {{ seatLimit() }} seats used\n      </span>\n\n      <div data-slot=\"invite-members-compose\" class=\"space-y-2\">\n        <div class=\"flex flex-col gap-2 sm:flex-row sm:items-start\">\n          <div class=\"min-w-0 flex-1 space-y-1.5\">\n            <ui-input\n              [value]=\"draft()\"\n              (valueChange)=\"draft.set($event)\"\n              type=\"text\"\n              placeholder=\"name@company.com — paste a list to queue several at once\"\n              [status]=\"error() ? 'error' : undefined\"\n              [attr.aria-describedby]=\"error() ? 'invite-members-email-error' : null\"\n              aria-label=\"Email addresses to invite\"\n              (keydown.enter)=\"submit()\"\n              (paste)=\"onPaste($event)\"\n            />\n            @if (error()) {\n              <p id=\"invite-members-email-error\" class=\"text-destructive text-xs font-medium\">{{ error() }}</p>\n            }\n          </div>\n          <ui-select [value]=\"role()\" (valueChange)=\"setRole($event)\">\n            <button ui-select-trigger class=\"w-full sm:w-32\" aria-label=\"Invite as\">\n              <ui-select-value placeholder=\"Role\" />\n            </button>\n            <ui-select-content>\n              <ui-select-item value=\"admin\">Admin</ui-select-item>\n              <ui-select-item value=\"editor\">Editor</ui-select-item>\n              <ui-select-item value=\"viewer\">Viewer</ui-select-item>\n            </ui-select-content>\n          </ui-select>\n          <button ui-button class=\"shrink-0\" [disabled]=\"!canSend()\" (click)=\"submit()\">\n            <lucide-icon [img]=\"Send\" />\n            Send invite\n          </button>\n        </div>\n\n        @if (queue().length) {\n          <div class=\"flex flex-wrap items-center gap-1.5\">\n            @for (email of queue(); track email) {\n              <span [class]=\"CHIP\">\n                <span class=\"truncate\">{{ email }}</span>\n                <button\n                  type=\"button\"\n                  [class]=\"CHIP_REMOVE\"\n                  [attr.aria-label]=\"'Remove ' + email + ' from queue'\"\n                  (click)=\"removeQueued(email)\"\n                >\n                  <lucide-icon [img]=\"X\" class=\"size-3\" />\n                </button>\n              </span>\n            }\n            <span class=\"text-muted-foreground text-xs\">{{ queue().length }} queued — pick a role and send</span>\n          </div>\n        }\n      </div>\n\n      <div data-slot=\"invite-members-list\" class=\"mt-5\">\n        <div class=\"mb-1 flex items-center justify-between\">\n          <h3 class=\"text-muted-foreground text-xs font-semibold tracking-widest uppercase\">Invites</h3>\n          <span class=\"text-muted-foreground text-xs tabular-nums\">{{ invites().length }} total</span>\n        </div>\n\n        @if (invites().length) {\n          <ul class=\"divide-y\">\n            @for (invite of invites(); track invite.id) {\n              <li data-slot=\"invite-members-row\" class=\"flex flex-wrap items-center gap-3 py-3\">\n                <ui-avatar class=\"size-8 shrink-0\">\n                  <ui-avatar-fallback class=\"text-xs\">{{ initialsFor(invite.email) }}</ui-avatar-fallback>\n                </ui-avatar>\n                <div class=\"min-w-[10rem] flex-1\">\n                  <p class=\"truncate text-sm font-medium\">{{ invite.email }}</p>\n                  <p class=\"text-muted-foreground text-xs\">Sent {{ sentAgo(invite.sentAt) }}</p>\n                </div>\n                <span ui-badge [variant]=\"roleVariant(invite.role)\" class=\"shrink-0\">\n                  {{ roleLabel(invite.role) }}\n                </span>\n                <span ui-badge [variant]=\"statusVariant(invite.status)\" class=\"shrink-0\">\n                  {{ statusLabel(invite.status) }}\n                </span>\n                <div class=\"flex shrink-0 items-center gap-0.5\">\n                  @if (invite.status !== 'accepted') {\n                    <button\n                      ui-button\n                      variant=\"ghost\"\n                      size=\"icon-sm\"\n                      title=\"Resend\"\n                      [attr.aria-label]=\"'Resend invite to ' + invite.email\"\n                      (click)=\"resend(invite)\"\n                    >\n                      <lucide-icon [img]=\"RotateCw\" />\n                    </button>\n                  }\n                  @if (confirmId() !== invite.id) {\n                    <button\n                      ui-button\n                      variant=\"ghost\"\n                      size=\"icon-sm\"\n                      title=\"Revoke\"\n                      [attr.aria-label]=\"'Revoke invite for ' + invite.email\"\n                      (click)=\"askRevoke(invite.id)\"\n                    >\n                      <lucide-icon [img]=\"X\" />\n                    </button>\n                  } @else {\n                    <button\n                      ui-button\n                      variant=\"destructive\"\n                      size=\"xs\"\n                      (click)=\"revoke(invite)\"\n                      (keydown.escape)=\"cancelConfirm()\"\n                    >\n                      Confirm?\n                    </button>\n                  }\n                </div>\n              </li>\n            }\n          </ul>\n        } @else {\n          <div class=\"flex flex-col items-center rounded-lg border border-dashed px-6 py-10 text-center\">\n            <div class=\"bg-muted mb-3 rounded-full p-3\">\n              <lucide-icon [img]=\"UserPlus\" class=\"text-muted-foreground size-5\" />\n            </div>\n            <p class=\"text-sm font-medium\">No invites yet</p>\n            <p class=\"text-muted-foreground mt-0.5 text-xs\">Send an invite above and it will show up here.</p>\n          </div>\n        }\n      </div>\n    </div>\n  `,\n})\nexport class UiInviteMembersComponent {\n  protected readonly RotateCw = RotateCw\n  protected readonly Send = Send\n  protected readonly UserPlus = UserPlus\n  protected readonly X = X\n\n  protected readonly CHIP = CHIP\n  protected readonly CHIP_REMOVE = CHIP_REMOVE\n\n  @Input() title = 'Invite members'\n  @Input() description = 'Bring teammates into your workspace. Invites expire after 14 days.'\n  @Input('seatLimit') set seatLimitInput(v: number | undefined) {\n    this.seatLimitValue.set(v ?? 20)\n  }\n  @Input() set memberSeats(v: number | undefined) {\n    this.memberSeatsValue.set(v ?? 16)\n  }\n  @Input() set initialInvites(v: InviteEntry[] | undefined) {\n    this.invites.set((v ?? DEFAULT_INVITES).map((invite) => ({ ...invite })))\n  }\n  @Input() set initialDraft(v: string | undefined) {\n    this.draft.set(v ?? '')\n    this.error.set(v && !EMAIL_RE.test(v.trim()) ? `“${v.trim()}” is not a valid email address.` : null)\n  }\n  @Input() set initialQueue(v: string[] | undefined) {\n    this.queue.set([...(v ?? [])])\n  }\n  @Input('class') className?: string\n\n  readonly invites = signal<InviteEntry[]>(DEFAULT_INVITES.map((invite) => ({ ...invite })))\n  readonly seatLimitValue = signal(20)\n  readonly memberSeatsValue = signal(16)\n  readonly draft = signal('')\n  readonly queue = signal<string[]>([])\n  readonly role = signal<InviteRole>('editor')\n  readonly error = signal<string | null>(null)\n  readonly confirmId = signal<string | null>(null)\n  private confirmTimer: ReturnType<typeof setTimeout> | undefined\n\n  constructor() {\n    inject(DestroyRef).onDestroy(() => clearTimeout(this.confirmTimer))\n  }\n\n  readonly seatLimit = computed(() => this.seatLimitValue())\n  readonly seatsUsed = computed(\n    () => this.memberSeatsValue() + this.invites().filter((invite) => invite.status === 'pending').length,\n  )\n  readonly nearLimit = computed(() => this.seatsUsed() / this.seatLimit() >= 0.9)\n  readonly seatsPillClass = computed(() => cn(SEATS_PILL, this.nearLimit() ? SEATS_PILL_WARN : SEATS_PILL_OK))\n  readonly canSend = computed(() => this.draft().trim().length > 0 || this.queue().length > 0)\n\n  setRole(value: string): void {\n    this.role.set(value as InviteRole)\n  }\n\n  roleLabel(role: InviteRole): string {\n    return ROLE_LABEL[role]\n  }\n\n  roleVariant(role: InviteRole): BadgeVariants['variant'] {\n    return ROLE_VARIANT[role]\n  }\n\n  statusLabel(status: InviteStatus): string {\n    return STATUS_LABEL[status]\n  }\n\n  statusVariant(status: InviteStatus): BadgeVariants['variant'] {\n    return STATUS_VARIANT[status]\n  }\n\n  private enqueue(emails: string[]): void {\n    for (const email of emails) {\n      if (!this.queue().some((queued) => queued.toLowerCase() === email.toLowerCase())) {\n        this.queue.update((prev) => [...prev, email])\n      }\n    }\n  }\n\n  private mergeQueue(emails: string[]): string[] {\n    const next = [...this.queue()]\n    for (const email of emails) {\n      if (!next.some((queued) => queued.toLowerCase() === email.toLowerCase())) next.push(email)\n    }\n    return next\n  }\n\n  private addInvites(emails: string[]): void {\n    const stamp = Date.now()\n    const next: InviteEntry[] = emails.map((email, i) => ({\n      id: `inv-${stamp}-${i}`,\n      email,\n      role: this.role(),\n      status: 'pending',\n      sentAt: new Date(),\n    }))\n    this.invites.update((prev) => [...next, ...prev])\n  }\n\n  /** Enter / Send invite: a lone address sends straight away, several queue as chips. */\n  submit(): void {\n    const tokens = tokenize(this.draft())\n    const valid = tokens.filter((token) => EMAIL_RE.test(token))\n    const invalid = tokens.filter((token) => !EMAIL_RE.test(token))\n\n    if (tokens.length === 1 && valid.length === 1) {\n      this.addInvites(valid)\n      this.draft.set('')\n      this.error.set(null)\n      return\n    }\n\n    if (invalid.length) {\n      this.error.set(invalidMessage(invalid))\n      this.draft.set(invalid.join(', '))\n      this.queue.set(this.mergeQueue(valid))\n      return\n    }\n\n    // Nothing invalid: stage everything (new addresses plus queued chips) and flush in one pass.\n    const merged = this.mergeQueue(valid)\n    if (merged.length) this.addInvites(merged)\n    this.error.set(null)\n    this.draft.set('')\n    this.queue.set([])\n  }\n\n  /** A paste containing separators queues every valid address as a chip. */\n  onPaste(event: ClipboardEvent): void {\n    const text = event.clipboardData?.getData('text') ?? ''\n    if (!text || !/[\\s,;]/.test(text.trim())) return\n    event.preventDefault()\n    const tokens = tokenize(text)\n    this.queue.set(this.mergeQueue(tokens.filter((token) => EMAIL_RE.test(token))))\n    const invalid = tokens.filter((token) => !EMAIL_RE.test(token))\n    this.error.set(invalid.length ? invalidMessage(invalid) : null)\n  }\n\n  removeQueued(email: string): void {\n    this.queue.update((prev) => prev.filter((queued) => queued !== email))\n  }\n\n  resend(invite: InviteEntry): void {\n    this.invites.update((prev) =>\n      prev.map((candidate) =>\n        candidate.id === invite.id ? { ...candidate, sentAt: new Date(), status: 'pending' as const } : candidate,\n      ),\n    )\n    this.cancelConfirm()\n  }\n\n  askRevoke(id: string): void {\n    this.confirmId.set(id)\n    clearTimeout(this.confirmTimer)\n    this.confirmTimer = setTimeout(() => this.confirmId.set(null), 4000)\n  }\n\n  revoke(invite: InviteEntry): void {\n    this.invites.update((prev) => prev.filter((candidate) => candidate.id !== invite.id))\n    this.cancelConfirm()\n  }\n\n  cancelConfirm(): void {\n    clearTimeout(this.confirmTimer)\n    this.confirmId.set(null)\n  }\n\n  initialsFor(email: string): string {\n    const local = email.split('@')[0] ?? ''\n    const parts = local.split(/[._-]+/).filter(Boolean)\n    const first = parts[0] ?? ''\n    const second = parts[1] ?? ''\n    if (first && second) return (first.charAt(0) + second.charAt(0)).toUpperCase()\n    return local.slice(0, 2).toUpperCase()\n  }\n\n  sentAgo(date: Date): string {\n    const seconds = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000))\n    if (seconds < 60) return 'just now'\n    const minutes = Math.floor(seconds / 60)\n    if (minutes < 60) return `${minutes}m ago`\n    const hours = Math.floor(minutes / 60)\n    if (hours < 24) return `${hours}h ago`\n    const days = Math.floor(hours / 24)\n    if (days < 7) return `${days}d ago`\n    return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })\n  }\n}\n",
      "type": "registry:block",
      "target": "~/src/app/components/blocks/invite-members/invite-members.component.ts"
    },
    {
      "path": "packages/registry-angular/blocks/invite-members/index.ts",
      "content": "export {\n  UiInviteMembersComponent,\n  type InviteEntry,\n  type InviteRole,\n  type InviteStatus,\n} from './invite-members.component'\n",
      "type": "registry:block",
      "target": "~/src/app/components/blocks/invite-members/index.ts"
    }
  ],
  "dependencies": [
    "lucide-angular"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/angular/avatar.json",
    "https://uipkge.dev/r/angular/badge.json",
    "https://uipkge.dev/r/angular/button.json",
    "https://uipkge.dev/r/angular/input.json",
    "https://uipkge.dev/r/angular/select.json",
    "https://uipkge.dev/r/angular/section-card.json"
  ],
  "description": "Team invite surface in a SectionCard. Header carries a live seats counter tinting warning at ≥90% capacity. Compose takes one email or a comma/space-separated paste — several addresses queue as removable chips first, plus an Admin/Editor/Viewer role Select and basic validation with a destructive error state. Rows show avatar initials, email, role/status badges (pending/accepted/expired), sent-ago timestamps, Resend (re-activates expired invites) and Revoke behind a two-click inline confirm. Self-contained stateful demo — swap DEFAULT_INVITES for your data source.",
  "categories": [
    "security",
    "dashboard",
    "communication"
  ]
}