Перейти к содержимому
v2.5.0
English
Русский
Документация
Все шаблоны

Участники команды

Участники с ролями, диалог приглашения с проверкой каждого адреса, ожидающие приглашения и занятые места.

Код

docs/examples/templates/team-members.tsx
import { useId, useState, type CSSProperties, type FormEvent } from 'react';
import {
  Avatar,
  AvatarFallback,
  AvatarStack,
  AvatarStackItem,
  Badge,
  Button,
  Card,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  HelperText,
  Icon,
  IconButton,
  Label,
  Meter,
  MeterTrack,
  Select,
  SelectCombobox,
  SelectIcon,
  SelectListbox,
  SelectOption,
  SelectValue,
  Table,
  TableBody,
  TableData,
  TableHead,
  TableHeading,
  TableRow,
  Text,
  TextArea,
  VisuallyHidden,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

type Role = 'owner' | 'admin' | 'editor' | 'viewer';

interface Member {
  id: string
  name: string
  email: string
  role: Role
  lastActive: string
}

interface Invite {
  email: string
  role: Role
  sent: string
  resent: boolean
}

const SEATS = 10;
const STACK_SIZE = 4;

const roles: Record<Role, string> = {
  owner: 'Owner',
  admin: 'Admin',
  editor: 'Editor',
  viewer: 'Viewer',
};

const assignable: Role[] = ['admin', 'editor', 'viewer'];

const initialMembers: Member[] = [
  { id: 'm1', name: 'Elena Moreau', email: 'elena.moreau@northwind.io', role: 'owner', lastActive: 'Now' },
  { id: 'm2', name: 'Omar Haddad', email: 'omar.haddad@northwind.io', role: 'admin', lastActive: '12 min ago' },
  { id: 'm3', name: 'Lena Fischer', email: 'lena.fischer@northwind.io', role: 'editor', lastActive: '2 hours ago' },
  { id: 'm4', name: 'Kenji Watanabe', email: 'kenji.watanabe@northwind.io', role: 'editor', lastActive: 'Yesterday' },
  { id: 'm5', name: 'Ana Souza', email: 'ana.souza@northwind.io', role: 'viewer', lastActive: '3 days ago' },
  { id: 'm6', name: 'Tom Becker', email: 'tom.becker@northwind.io', role: 'viewer', lastActive: 'Oct 21' },
];

const initialInvites: Invite[] = [
  { email: 'priya.nair@northwind.io', role: 'editor', sent: 'Invited 2 days ago', resent: false },
  { email: 'jonas.berg@northwind.io', role: 'viewer', sent: 'Invited 5 days ago', resent: false },
];

const field = { display: 'grid', gap: 'var(--spacing-3)', alignContent: 'start' } as const;
const nowrap = { whiteSpace: 'nowrap' } as const;
const closeButton = { flexShrink: 0, '--icon-button-size': '24px', '--icon-button-br-ratio': 0.5 } as CSSProperties;
const dialogHeader = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' } as const;
const actions = { display: 'flex', flexWrap: 'wrap', justifyContent: 'flex-end', gap: 'var(--spacing-4)' } as const;

const destructive = {
  '--button-background-color': 'var(--color-red-accent-500)',
  '--button-background-color-hover': 'var(--color-red-accent-600)',
  '--button-background-color-active': 'var(--color-red-accent-700)',
} as CSSProperties;

const getInitials = (name: string) => name.split(' ').map((part) => part[0]).join('').slice(0, 2);

const parseEmails = (value: string) => {
  const entries = value.split(/[\s,;]+/).map((entry) => entry.trim().toLowerCase()).filter((entry) => entry !== '');

  return [...new Set(entries)];
};

export default function TeamMembers() {
  const id = useId();
  const [members, setMembers] = useState(initialMembers);
  const [invites, setInvites] = useState(initialInvites);
  const [inviteRole, setInviteRole] = useState<Role>('editor');
  const [inviteOpen, setInviteOpen] = useState(false);
  const [inviteError, setInviteError] = useState('');
  const [target, setTarget] = useState<Member | null>(null);
  const [removeOpen, setRemoveOpen] = useState(false);
  const [notice, setNotice] = useState('');

  const used = members.length + invites.length;
  const available = SEATS - used;
  const stack = members.slice(0, STACK_SIZE);
  const hidden = members.length - stack.length;

  const changeRole = (member: Member, role: Role | undefined) => {
    if (role === undefined) return;

    setMembers((current) => current.map((item) => (item.id === member.id ? { ...item, role } : item)));
    setNotice(`${member.name}’s role changed to ${roles[role]}.`);
  };

  const validateInvite = (emails: string[]) => {
    const taken = [...members.map((member) => member.email), ...invites.map((invite) => invite.email)];
    const invalid = emails.filter((email) => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email));
    const existing = emails.find((email) => taken.includes(email));

    if (emails.length === 0) return 'Enter at least one email address.';
    if (invalid.length === 1) return `${invalid[0]} doesn’t look like an email address.`;
    if (invalid.length > 1) return `These don’t look like email addresses: ${invalid.join(', ')}.`;
    if (existing) return `${existing} is already on the team or invited.`;
    if (emails.length > available) return `You have ${available} of ${SEATS} seats left. Remove ${emails.length - available} address${emails.length - available === 1 ? '' : 'es'} or add seats in Billing.`;

    return '';
  };

  const sendInvites = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const form = event.currentTarget;
    const emails = parseEmails(String(new FormData(form).get('emails')));
    const error = validateInvite(emails);

    setInviteError(error);

    if (error !== '') {
      const element = form.elements.namedItem('emails');
      if (element instanceof HTMLElement) element.focus();
      return;
    }

    setInvites((current) => [...emails.map((email) => ({ email, role: inviteRole, sent: 'Invited just now', resent: false })), ...current]);
    setNotice(emails.length === 1 ? `Invite sent to ${emails[0]}.` : `${emails.length} invites sent.`);
    setInviteOpen(false);
  };

  const changeInviteOpen = (open: boolean) => {
    setInviteOpen(open);

    if (!open) return;

    setInviteError('');
    setInviteRole('editor');
  };

  const askRemove = (member: Member) => {
    setTarget(member);
    setRemoveOpen(true);
  };

  const removeMember = () => {
    if (target === null) return;

    setMembers((current) => current.filter((member) => member.id !== target.id));
    setNotice(`${target.name} was removed from the workspace.`);
  };

  const resend = (invite: Invite) => {
    setInvites((current) => current.map((item) => (item.email === invite.email ? { ...item, resent: true, sent: 'Resent just now' } : item)));
    setNotice(`Invite resent to ${invite.email}.`);
  };

  const revoke = (invite: Invite) => {
    setInvites((current) => current.filter((item) => item.email !== invite.email));
    setNotice(`Invite for ${invite.email} was revoked.`);
  };

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-16)', width: '100%', maxWidth: 960, marginInline: 'auto' }}>
      <header style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-end', justifyContent: 'space-between', gap: 'var(--spacing-8) var(--spacing-12)' }}>
        <div style={{ display: 'grid', gap: 'var(--spacing-2)', flex: '1 1 320px' }}>
          <Heading as='h1' size={7} fontWeight={700}>Team members</Heading>
          <Text size={3} style={{ color: 'var(--label-secondary)' }}>Invite people to Northwind and choose what they can change.</Text>
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-8)' }}>
          <AvatarStack size='sm' role='group' aria-label={`${members.length} members`}>
            {stack.map((member) => (
              <AvatarStackItem key={member.id} role='img' aria-label={member.name} title={member.name}>
                <AvatarFallback>{getInitials(member.name)}</AvatarFallback>
              </AvatarStackItem>
            ))}
            {hidden > 0 && (
              <AvatarStackItem variant='secondary' role='img' aria-label={`and ${hidden} more`} title={members.slice(STACK_SIZE).map((member) => member.name).join(', ')}>
                <AvatarFallback>{`+${hidden}`}</AvatarFallback>
              </AvatarStackItem>
            )}
          </AvatarStack>
          <Dialog open={inviteOpen} onChangeOpen={changeInviteOpen}>
            <DialogTrigger asChild>
              <Button size='xs'>Invite members</Button>
            </DialogTrigger>
            <DialogContent aria-labelledby={`${id}-invite-title`} aria-describedby={`${id}-invite-description`}>
              <div style={dialogHeader}>
                <Heading id={`${id}-invite-title`} as='h2' size={4} fontWeight={600}>Invite members</Heading>
                <DialogClose asChild>
                  <IconButton size='xs' aria-label='Close' variant='secondary' appearance='ghost' style={closeButton}>
                    <Icon size='xs'><XIcon strokeWidth={2.5} /></Icon>
                  </IconButton>
                </DialogClose>
              </div>
              <Text id={`${id}-invite-description`} size={3} style={{ display: 'block', marginBlockStart: 'var(--spacing-4)', color: 'var(--label-secondary)' }}>
                Each invite takes a seat until it is accepted or revoked.
              </Text>
              <form noValidate onSubmit={sendInvites} style={{ display: 'grid', gap: 'var(--spacing-8)', marginBlockStart: 'var(--spacing-10)' }}>
                <div style={field}>
                  <Label htmlFor={`${id}-emails`}>Email addresses</Label>
                  <TextArea
                    id={`${id}-emails`}
                    name='emails'
                    rows={3}
                    fullWidth
                    error={inviteError !== ''}
                    placeholder='priya@northwind.io, sam@northwind.io'
                    spellCheck={false}
                    aria-invalid={inviteError !== ''}
                    aria-describedby={inviteError === '' ? `${id}-emails-hint` : `${id}-emails-error`}
                    onChange={() => setInviteError('')}
                  />
                  {inviteError === ''
                    ? <HelperText id={`${id}-emails-hint`} style={{ fontVariantNumeric: 'tabular-nums' }}>{`Separate addresses with commas. ${available} of ${SEATS} seats available.`}</HelperText>
                    : <HelperText id={`${id}-emails-error`} error>{inviteError}</HelperText>}
                </div>
                <div style={field}>
                  <Label htmlFor={`${id}-invite-role`}>Role</Label>
                  <Select value={inviteRole} onChangeValue={(role) => setInviteRole(role ?? 'editor')}>
                    <SelectCombobox id={`${id}-invite-role`}>
                      <SelectValue />
                      <SelectIcon />
                    </SelectCombobox>
                    <SelectListbox>
                      {assignable.map((role) => <SelectOption key={role} value={role}>{roles[role]}</SelectOption>)}
                    </SelectListbox>
                  </Select>
                </div>
                <div style={field}>
                  <Label htmlFor={`${id}-message`}>Message (optional)</Label>
                  <TextArea id={`${id}-message`} name='message' rows={3} fullWidth placeholder='Hi! Join us in Northwind to review the Q4 roadmap.' />
                </div>
                <div style={{ ...actions, marginBlockStart: 'var(--spacing-4)' }}>
                  <DialogClose asChild>
                    <Button size='xs' type='button' variant='secondary' appearance='ghost'>Cancel</Button>
                  </DialogClose>
                  <Button size='xs' type='submit'>Send invites</Button>
                </div>
              </form>
            </DialogContent>
          </Dialog>
        </div>
      </header>

      <Card style={{ alignItems: 'stretch', gap: 'var(--spacing-3)' }}>
        <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', justifyContent: 'space-between', gap: 'var(--spacing-2) var(--spacing-8)' }}>
          <Text id={`${id}-seats`} size={3} fontWeight={500}>Seats</Text>
          <Text size={2} style={{ color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}>{`${used} of ${SEATS} seats used`}</Text>
        </div>
        <Meter value={used} max={SEATS} high={SEATS - 1} valueText={`${used} of ${SEATS} seats used`} aria-labelledby={`${id}-seats`} fullWidth>
          <MeterTrack />
        </Meter>
        <Text size={2} style={{ color: 'var(--label-secondary)' }}>
          {available === 0 ? 'All seats are taken. Add seats in Billing to invite more people.' : 'Pending invites count toward your seats.'}
        </Text>
      </Card>

      <section aria-labelledby={`${id}-members`} style={{ display: 'grid', gap: 'var(--spacing-8)' }}>
        <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', justifyContent: 'space-between', gap: 'var(--spacing-2) var(--spacing-8)' }}>
          <Heading id={`${id}-members`} as='h2' size={4} fontWeight={600}>{`Members · ${members.length}`}</Heading>
          <Text size={2} role='status' style={{ color: 'var(--label-secondary)' }}>{notice}</Text>
        </div>
        <div
          role='region'
          aria-labelledby={`${id}-members`}
          tabIndex={0}
          style={{ position: 'relative', minWidth: 0, overflowX: 'auto', border: '1px solid var(--separator-opaque)', borderRadius: 'var(--radius-4)', background: 'var(--surface-primary)' }}
        >
          <Table fullWidth edgePadded style={{ borderStyle: 'hidden' }}>
            <TableHead>
              <TableRow>
                <TableHeading scope='col' dataAlign='start'>Member</TableHeading>
                <TableHeading scope='col' dataAlign='start'>Role</TableHeading>
                <TableHeading scope='col' dataAlign='start'>Status</TableHeading>
                <TableHeading scope='col' dataAlign='start'>Last active</TableHeading>
                <TableHeading scope='col' dataAlign='end'><VisuallyHidden>Actions</VisuallyHidden></TableHeading>
              </TableRow>
            </TableHead>
            <TableBody>
              {members.map((member) => (
                <TableRow key={member.id}>
                  <TableData dataAlign='start'>
                    <span style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-6)' }}>
                      <Avatar size='md' aria-hidden='true' style={{ flexShrink: 0 }}>
                        <AvatarFallback>{getInitials(member.name)}</AvatarFallback>
                      </Avatar>
                      <span style={{ display: 'grid' }}>
                        <Text size={3} fontWeight={500} style={nowrap}>{member.name}</Text>
                        <Text size={2} style={{ ...nowrap, color: 'var(--label-secondary)' }}>{member.email}</Text>
                      </span>
                    </span>
                  </TableData>
                  <TableData dataAlign='start'>
                    {member.role === 'owner'
                      ? <Text size={3} style={{ ...nowrap, paddingInline: 'var(--spacing-6)' }}>Owner</Text>
                      : (
                          <Select size='xs' value={member.role} onChangeValue={(role) => changeRole(member, role)} style={{ minWidth: 120 }}>
                            <SelectCombobox aria-label={`Role of ${member.name}`}>
                              <SelectValue />
                              <SelectIcon />
                            </SelectCombobox>
                            <SelectListbox>
                              {assignable.map((role) => <SelectOption key={role} value={role}>{roles[role]}</SelectOption>)}
                            </SelectListbox>
                          </Select>
                        )}
                  </TableData>
                  <TableData dataAlign='start'>
                    <Badge size='sm' variant='success'>Active</Badge>
                  </TableData>
                  <TableData dataAlign='start' style={{ ...nowrap, color: 'var(--label-secondary)' }}>{member.lastActive}</TableData>
                  <TableData dataAlign='end'>
                    {member.role !== 'owner' && (
                      <Button size='xs' variant='secondary' appearance='ghost' aria-label={`Remove ${member.name}`} onClick={() => askRemove(member)}>Remove</Button>
                    )}
                  </TableData>
                </TableRow>
              ))}
            </TableBody>
          </Table>
        </div>
      </section>

      <section aria-labelledby={`${id}-invites`} style={{ display: 'grid', gap: 'var(--spacing-8)' }}>
        <Heading id={`${id}-invites`} as='h2' size={4} fontWeight={600}>{`Pending invites · ${invites.length}`}</Heading>
        <Card style={{ alignItems: 'stretch', gap: 0, paddingBlock: 'var(--spacing-2)' }}>
          {invites.length === 0 && (
            <Text size={3} style={{ paddingBlock: 'var(--spacing-8)', color: 'var(--label-secondary)', textAlign: 'center' }}>No pending invites.</Text>
          )}
          {invites.length > 0 && (
            <ul style={{ display: 'grid', margin: 0, padding: 0, listStyle: 'none' }}>
              {invites.map((invite, index) => (
                <li
                  key={invite.email}
                  style={{
                    display: 'flex',
                    flexWrap: 'wrap',
                    alignItems: 'center',
                    gap: 'var(--spacing-4) var(--spacing-6)',
                    paddingBlock: 'var(--spacing-6)',
                    borderBlockStart: index > 0 ? '1px solid var(--separator-opaque)' : undefined,
                  }}
                >
                  <Avatar size='md' variant='secondary' aria-hidden='true' style={{ flexShrink: 0 }}>
                    <AvatarFallback>{invite.email[0].toUpperCase()}</AvatarFallback>
                  </Avatar>
                  <span style={{ display: 'grid', gap: 'var(--spacing-1)', flex: '1 1 200px', minWidth: 0 }}>
                    <Text size={3} fontWeight={500} style={{ overflowWrap: 'anywhere' }}>{invite.email}</Text>
                    <span style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 'var(--spacing-1) var(--spacing-4)' }}>
                      <Badge size='xs' variant='info'>Invited</Badge>
                      <Text size={2} style={{ color: 'var(--label-secondary)' }}>{`${roles[invite.role]} · ${invite.sent}`}</Text>
                    </span>
                  </span>
                  <span style={{ display: 'flex', gap: 'var(--spacing-2)', marginInlineStart: 'auto' }}>
                    <Button size='xs' variant='secondary' appearance='ghost' disabled={invite.resent} aria-label={`Resend invite to ${invite.email}`} onClick={() => resend(invite)}>
                      {invite.resent ? 'Sent' : 'Resend'}
                    </Button>
                    <Button size='xs' variant='secondary' appearance='ghost' aria-label={`Revoke invite for ${invite.email}`} onClick={() => revoke(invite)}>Revoke</Button>
                  </span>
                </li>
              ))}
            </ul>
          )}
        </Card>
      </section>

      <Dialog open={removeOpen} onChangeOpen={setRemoveOpen}>
        <DialogContent aria-labelledby={`${id}-remove-title`} aria-describedby={`${id}-remove-description`}>
          <div style={dialogHeader}>
            <Heading id={`${id}-remove-title`} as='h2' size={4} fontWeight={600}>{`Remove ${target?.name ?? 'member'}?`}</Heading>
            <DialogClose asChild>
              <IconButton size='xs' aria-label='Close' variant='secondary' appearance='ghost' style={closeButton}>
                <Icon size='xs'><XIcon strokeWidth={2.5} /></Icon>
              </IconButton>
            </DialogClose>
          </div>
          <Text id={`${id}-remove-description`} size={3} style={{ display: 'block', marginBlockStart: 'var(--spacing-4)', color: 'var(--label-secondary)' }}>
            They lose access to Northwind right away. Their files and comments stay in the workspace, and the seat becomes free.
          </Text>
          <form method='dialog' onSubmit={removeMember} style={{ ...actions, marginBlockStart: 'var(--spacing-12)' }}>
            <DialogClose asChild>
              <Button size='xs' type='button' variant='secondary' appearance='ghost'>Cancel</Button>
            </DialogClose>
            <Button size='xs' type='submit' style={destructive}>Remove member</Button>
          </form>
        </DialogContent>
      </Dialog>
    </div>
  );
}

Использованные компоненты