Invite Members
blocksecurityTeam 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
$pnpm dlx uipkge-ng@latest add invite-members$npx uipkge-ng@latest add invite-members$yarn dlx uipkge-ng@latest add invite-members$bunx uipkge-ng@latest add invite-membershttps://uipkge.dev/r/angular/invite-members.jsonInstalls to:src/app/components/blocks/invite-members/Variants
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
InviteEntryinterface InviteEntry {
id: string
email: string
role: InviteRole
status: InviteStatus
sentAt: Date
}npm dependencies
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
export { UiInviteMembersComponent, type InviteEntry, type InviteRole, type InviteStatus, } from './invite-members.component'
Raw manifest:https://uipkge.dev/r/angular/invite-members.json