Team
Search members, change a role in place, remove someone with a chance to undo, and invite people by email with validation.
Add this example to a project that has prfct installed. The command writes the files below and installs the components they use.
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/team.json"use client"
import * as React from "react"
import { MailIcon, SearchIcon, UserPlusIcon, XIcon } from "lucide-react"
import aurora from "./aurora.svg"
import ember from "./ember.svg"
import tide from "./tide.svg"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from "@/components/ui/empty"
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group"
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from "@/components/ui/item"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { toast } from "@/components/ui/toast"
/** The roles an admin can give, by name. The owner's is fixed. */
const roles = { admin: "Admin", member: "Member", viewer: "Viewer" } as const
type Role = keyof typeof roles
type Member = {
name: string
email: string
role: Role | "owner"
image?: string
}
type Invitation = { email: string; role: Role; sent: string }
const initialMembers: Member[] = [
{
name: "Maya Chen",
email: "maya@northwind.dev",
role: "owner",
image: aurora.src,
},
{
name: "Jonah Okafor",
email: "jonah@northwind.dev",
role: "admin",
image: ember.src,
},
{
name: "Lena Park",
email: "lena@northwind.dev",
role: "member",
image: tide.src,
},
{ name: "Priya Raman", email: "priya@northwind.dev", role: "member" },
{ name: "Tomás Rivera", email: "tomas@northwind.dev", role: "viewer" },
]
const initialInvitations: Invitation[] = [
{ email: "sam@northwind.dev", role: "member", sent: "Invited 2 days ago" },
{ email: "ines@northwind.dev", role: "viewer", sent: "Invited last week" },
]
const EMAIL = /^\S+@\S+\.\S+$/
const initials = (name: string) =>
name
.split(" ")
.map((part) => part[0])
.join("")
function RoleSelect({
name,
value,
onChange,
}: {
/** Whose role it is: every row has a select, so each needs its own name. */
name: string
value: Role
onChange: (role: Role) => void
}) {
return (
<Select
items={roles}
value={value}
onValueChange={(role) => {
if (role) onChange(role)
}}
>
<SelectTrigger
size="sm"
className="w-36 @max-md:w-auto @max-md:flex-1"
aria-label={`Role of ${name}`}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(roles).map(([role, label]) => (
<SelectItem key={role} value={role}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
function MemberRow({
member,
onRoleChange,
onRemove,
}: {
member: Member
onRoleChange: (role: Role) => void
onRemove: () => void
}) {
return (
<Item variant="outline" size="sm">
<ItemMedia>
<Avatar>
{member.image && <AvatarImage src={member.image} alt="" />}
<AvatarFallback>{initials(member.name)}</AvatarFallback>
</Avatar>
</ItemMedia>
{/* min-w-0 lets a long email truncate instead of wrapping the row. */}
<ItemContent className="min-w-0 gap-0.5">
<ItemTitle>{member.name}</ItemTitle>
<ItemDescription className="truncate">{member.email}</ItemDescription>
</ItemContent>
{member.role === "owner" ? (
<span className="px-3 text-sm text-muted-foreground">Owner</span>
) : (
// On a phone the controls take a line of their own under the name.
<ItemActions className="@max-md:basis-full">
<RoleSelect
name={member.name}
value={member.role}
onChange={onRoleChange}
/>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Remove ${member.name}`}
onClick={onRemove}
>
<XIcon />
</Button>
</ItemActions>
)}
</Item>
)
}
function InvitationRow({
invitation,
onRevoke,
}: {
invitation: Invitation
onRevoke: () => void
}) {
return (
<Item variant="outline" size="sm">
<ItemMedia>
<Avatar>
<AvatarFallback>
<MailIcon className="size-4" aria-hidden="true" />
</AvatarFallback>
</Avatar>
</ItemMedia>
<ItemContent className="min-w-0 gap-0.5">
<ItemTitle className="truncate">{invitation.email}</ItemTitle>
<ItemDescription>
{roles[invitation.role]} · {invitation.sent}
</ItemDescription>
</ItemContent>
<ItemActions>
<Button variant="ghost" size="sm" onClick={onRevoke}>
Revoke
</Button>
</ItemActions>
</Item>
)
}
function InviteDialog({
taken,
onInvite,
}: {
/** Addresses that already belong to a member or an invitation. */
taken: string[]
onInvite: (invitation: Invitation) => void
}) {
const [open, setOpen] = React.useState(false)
const [error, setError] = React.useState<string>()
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const data = new FormData(event.currentTarget)
const email = String(data.get("email")).trim().toLowerCase()
const problem = !EMAIL.test(email)
? "Enter an email address like ada@example.com."
: taken.includes(email)
? "This person is already on the team or invited."
: undefined
if (problem) {
setError(problem)
event.currentTarget.querySelector("input")?.focus()
return
}
const role = data.get("role") as Role
onInvite({ email, role, sent: "Invited just now" })
setOpen(false)
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next)
if (!next) setError(undefined)
}}
>
<DialogTrigger render={<Button />}>
<UserPlusIcon data-icon="inline-start" />
Invite people
</DialogTrigger>
<DialogContent>
<form noValidate onSubmit={submit} className="flex flex-col gap-5">
<DialogHeader>
<DialogTitle>Invite to Northwind</DialogTitle>
<DialogDescription>
They get an email with a link that works for seven days.
</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={error ? true : undefined}>
<FieldLabel htmlFor="invite-email">Email</FieldLabel>
<Input
id="invite-email"
name="email"
type="email"
autoComplete="off"
placeholder="ada@example.com"
onChange={() => setError(undefined)}
aria-invalid={error ? true : undefined}
aria-describedby={error ? "invite-error" : undefined}
/>
{error && <FieldError id="invite-error">{error}</FieldError>}
</Field>
<Field>
<FieldLabel htmlFor="invite-role">Role</FieldLabel>
<NativeSelect
id="invite-role"
name="role"
defaultValue="member"
className="w-full"
>
{Object.entries(roles).map(([role, label]) => (
<NativeSelectOption key={role} value={role}>
{label}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
</FieldGroup>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>
Cancel
</DialogClose>
<Button type="submit">Send invitation</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export default function TeamExample() {
const [members, setMembers] = React.useState(initialMembers)
const [invitations, setInvitations] = React.useState(initialInvitations)
const [query, setQuery] = React.useState("")
const needle = query.trim().toLowerCase()
const found = members.filter((member) =>
`${member.name} ${member.email}`.toLowerCase().includes(needle)
)
function changeRole(member: Member, role: Role) {
setMembers((current) =>
current.map((m) => (m.email === member.email ? { ...m, role } : m))
)
}
function remove(member: Member) {
const index = members.indexOf(member)
setMembers((current) => current.filter((m) => m.email !== member.email))
const id = toast.add({
title: `${member.name} was removed`,
actionProps: {
children: "Undo",
onClick: () => {
// Closed with the click: a second one would add the member twice.
toast.close(id)
setMembers((current) => current.toSpliced(index, 0, member))
},
},
})
}
function invite(invitation: Invitation) {
setInvitations((current) => [invitation, ...current])
toast.add({
title: "Invitation sent",
description: invitation.email,
type: "success",
})
}
return (
<div className="@container">
<div className="mx-auto flex max-w-3xl flex-col gap-8">
<section aria-labelledby="team-members" className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 id="team-members" className="text-heading-xs">
Members{" "}
<span className="font-normal text-muted-foreground">
{members.length}
</span>
</h2>
<InviteDialog
taken={[...members, ...invitations].map((entry) => entry.email)}
onInvite={invite}
/>
</div>
<InputGroup>
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput
placeholder="Search by name or email"
aria-label="Search members"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</InputGroup>
{found.length > 0 ? (
<ItemGroup>
{found.map((member) => (
<MemberRow
key={member.email}
member={member}
onRoleChange={(role) => changeRole(member, role)}
onRemove={() => remove(member)}
/>
))}
</ItemGroup>
) : (
<Empty className="border">
<EmptyHeader>
<EmptyTitle>No one matches “{query.trim()}”</EmptyTitle>
<EmptyDescription>
Check the spelling, or invite them to the team.
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</section>
{invitations.length > 0 && (
<section
aria-labelledby="team-invitations"
className="flex flex-col gap-3"
>
<h2 id="team-invitations" className="text-heading-xs">
Pending invitations{" "}
<span className="font-normal text-muted-foreground">
{invitations.length}
</span>
</h2>
<ItemGroup>
{invitations.map((invitation) => (
<InvitationRow
key={invitation.email}
invitation={invitation}
onRevoke={() =>
setInvitations((current) =>
current.filter((i) => i !== invitation)
)
}
/>
))}
</ItemGroup>
</section>
)}
</div>
</div>
)
}<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80">
<rect width="80" height="80" fill="#e4eaff"/>
<circle cx="40" cy="31" r="14" fill="#8ea6ff"/>
<path d="M12 80c0-15.5 12.5-26 28-26s28 10.5 28 26z" fill="#8ea6ff"/>
</svg><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80">
<rect width="80" height="80" fill="#fdebe0"/>
<circle cx="40" cy="31" r="14" fill="#f0a072"/>
<path d="M12 80c0-15.5 12.5-26 28-26s28 10.5 28 26z" fill="#f0a072"/>
</svg><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80">
<rect width="80" height="80" fill="#dcf2ef"/>
<circle cx="40" cy="31" r="14" fill="#6cbdb3"/>
<path d="M12 80c0-15.5 12.5-26 28-26s28 10.5 28 26z" fill="#6cbdb3"/>
</svg>import type { Metadata } from "next"
import Team from "@/components/team/team"
import { Toaster } from "@/components/ui/toast"
export const metadata: Metadata = {
title: "Team",
description: "Members and invitations: search, roles that change in place, removal with undo and an invite dialog.",
}
export default function TeamPage() {
return (
<Toaster>
<main className="mx-auto min-h-dvh w-full max-w-[90rem] p-4 sm:p-8">
<Team />
</main>
</Toaster>
)
}