Menu

Change language

GitHub repo

Invite Members

blocksecurity

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.

Installation

$npx uipkge-ng@latest add invite-members
Manifest:https://uipkge.dev/r/angular/invite-members.jsonInstalls to:src/app/components/blocks/invite-members/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

InviteEntry
interface InviteEntry {
  id: string
  email: string
  role: InviteRole
  status: InviteStatus
  sentAt: Date
}

Files installed (2)

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

Raw manifest:https://uipkge.dev/r/angular/invite-members.json