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

Настройки аккаунта

Форма профиля с проверкой, переключатели уведомлений и опасная зона с диалогом подтверждения.

Код

docs/examples/templates/account-settings.tsx
import { useId, useState, type CSSProperties, type FormEvent, type ReactNode } from 'react';
import {
  Alert,
  AlertContent,
  AlertIcon,
  AlertMain,
  AlertTitle,
  Avatar,
  AvatarFallback,
  Button,
  Card,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  HelperText,
  Icon,
  IconButton,
  Label,
  Separator,
  Switch,
  SwitchIndicator,
  SwitchInput,
  Text,
  TextArea,
  TextField,
  TextFieldInput,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

const ACCOUNT_EMAIL = 'elena.moreau@northwind.io';
const BIO_LIMIT = 160;

const notifications = [
  { name: 'mentions', title: 'Mentions and replies', description: 'When someone mentions you or replies to your comment.', checked: true },
  { name: 'assigned', title: 'Assigned to you', description: 'When an issue or a review is assigned to you.', checked: true },
  { name: 'digest', title: 'Weekly digest', description: 'A Monday summary of activity in your projects.', checked: false },
  { name: 'product', title: 'Product updates', description: 'New features and changes, about once a month.', checked: false },
];

const field = { display: 'grid', gap: 'var(--spacing-3)', alignContent: 'start' } as const;
const card = { alignItems: 'stretch', gap: 'var(--spacing-12)', minWidth: 0, flex: '999 1 420px' } as const;
const closeButton = { flexShrink: 0, '--icon-button-size': '24px', '--icon-button-br-ratio': 0.5 } as CSSProperties;

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;

interface ProfileErrors {
  name?: string
  email?: string
}

const validateProfile = (data: FormData) => {
  const errors: ProfileErrors = {};
  const name = String(data.get('name')).trim();
  const email = String(data.get('email')).trim();

  if (name === '') errors.name = 'Enter your name so teammates can find you.';
  if (email === '') errors.email = 'Enter the email you sign in with.';
  if (email !== '' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = 'Use the full address, like elena@northwind.io.';

  return errors;
};

const Section = ({ id, title, description, children }: { id: string, title: string, description: string, children: ReactNode }) => (
  <section aria-labelledby={id} style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--spacing-8) var(--spacing-16)' }}>
    <div style={{ display: 'grid', gap: 'var(--spacing-2)', alignContent: 'start', flex: '1 1 220px' }}>
      <Heading id={id} as='h2' size={4} fontWeight={600}>{title}</Heading>
      <Text size={2} style={{ color: 'var(--label-secondary)' }}>{description}</Text>
    </div>
    {children}
  </section>
);

export default function AccountSettings() {
  const id = useId();
  const [bio, setBio] = useState('Product designer working on billing and onboarding.');
  const [errors, setErrors] = useState<ProfileErrors>({});
  const [saved, setSaved] = useState(false);
  const [confirmation, setConfirmation] = useState('');
  const [deleted, setDeleted] = useState(false);

  const saveProfile = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const form = event.currentTarget;
    const next = validateProfile(new FormData(form));
    const invalid = Object.keys(next)[0];

    setErrors(next);
    setSaved(invalid === undefined);

    if (invalid === undefined) return;

    const element = form.elements.namedItem(invalid);
    if (element instanceof HTMLElement) element.focus();
  };

  const discardProfile = () => {
    setBio('Product designer working on billing and onboarding.');
    setErrors({});
    setSaved(false);
  };

  const deleteAccount = (event: FormEvent<HTMLFormElement>) => {
    if (confirmation !== ACCOUNT_EMAIL) {
      event.preventDefault();
      return;
    }

    setDeleted(true);
  };

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-16)', width: '100%', maxWidth: 960, marginInline: 'auto' }}>
      <header style={{ display: 'grid', gap: 'var(--spacing-2)' }}>
        <Heading as='h1' size={7} fontWeight={700}>Settings</Heading>
        <Text size={3} style={{ color: 'var(--label-secondary)' }}>Manage your profile, notifications and account.</Text>
      </header>

      <Section id={`${id}-profile`} title='Profile' description='Shown to people in your workspace and on comments you leave.'>
        <Card asChild style={card}>
          <form noValidate aria-labelledby={`${id}-profile`} onSubmit={saveProfile} onReset={discardProfile}>
            <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-6)', minWidth: 0 }}>
              <Avatar size='lg' aria-hidden='true' style={{ flexShrink: 0 }}>
                <AvatarFallback>EM</AvatarFallback>
              </Avatar>
              <div style={{ display: 'grid', minWidth: 0 }}>
                <Text size={3} fontWeight={500}>Elena Moreau</Text>
                <Text size={2} style={{ color: 'var(--label-secondary)', overflowWrap: 'anywhere' }}>Member since March 2024</Text>
              </div>
            </div>

            <div style={{ display: 'grid', gap: 'var(--spacing-8)' }}>
              <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 220px), 1fr))', gap: 'var(--spacing-8)' }}>
                <div style={field}>
                  <Label htmlFor={`${id}-name`}>Full name</Label>
                  <TextField error={errors.name !== undefined}>
                    <TextFieldInput
                      id={`${id}-name`}
                      name='name'
                      autoComplete='name'
                      defaultValue='Elena Moreau'
                      aria-invalid={errors.name !== undefined}
                      aria-describedby={errors.name ? `${id}-name-error` : undefined}
                    />
                  </TextField>
                  {errors.name && <HelperText id={`${id}-name-error`} error>{errors.name}</HelperText>}
                </div>
                <div style={field}>
                  <Label htmlFor={`${id}-email`}>Email</Label>
                  <TextField error={errors.email !== undefined}>
                    <TextFieldInput
                      id={`${id}-email`}
                      name='email'
                      type='email'
                      autoComplete='email'
                      defaultValue={ACCOUNT_EMAIL}
                      aria-invalid={errors.email !== undefined}
                      aria-describedby={errors.email ? `${id}-email-error` : undefined}
                    />
                  </TextField>
                  {errors.email && <HelperText id={`${id}-email-error`} error>{errors.email}</HelperText>}
                </div>
              </div>
              <div style={field}>
                <Label htmlFor={`${id}-bio`}>Bio</Label>
                <TextArea
                  id={`${id}-bio`}
                  name='bio'
                  rows={3}
                  maxLength={BIO_LIMIT}
                  fullWidth
                  value={bio}
                  aria-describedby={`${id}-bio-count`}
                  onChange={(event) => setBio(event.currentTarget.value)}
                />
                <HelperText id={`${id}-bio-count`} style={{ fontVariantNumeric: 'tabular-nums' }}>
                  {`${BIO_LIMIT - bio.length} characters left`}
                </HelperText>
              </div>
            </div>

            <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'flex-end', gap: 'var(--spacing-4)' }}>
              <Text size={2} role='status' style={{ marginInlineEnd: 'auto', color: 'var(--label-secondary)' }}>{saved ? 'Profile saved.' : ''}</Text>
              <Button size='xs' type='reset' variant='secondary' appearance='ghost'>Discard</Button>
              <Button size='xs' type='submit'>Save changes</Button>
            </div>
          </form>
        </Card>
      </Section>

      <Separator />

      <Section id={`${id}-notifications`} title='Email notifications' description='Choose what lands in your inbox. Changes apply right away.'>
        <Card style={{ ...card, gap: 'var(--spacing-10)' }} aria-labelledby={`${id}-notifications`}>
          {notifications.map((item) => (
            <div key={item.name} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
              <div style={{ display: 'grid', gap: 'var(--spacing-1)', minWidth: 0 }}>
                <Label htmlFor={`${id}-${item.name}`}>{item.title}</Label>
                <Text id={`${id}-${item.name}-description`} size={2} style={{ color: 'var(--label-secondary)' }}>{item.description}</Text>
              </div>
              <Switch style={{ flexShrink: 0 }}>
                <SwitchInput id={`${id}-${item.name}`} name={item.name} defaultChecked={item.checked} aria-describedby={`${id}-${item.name}-description`} />
                <SwitchIndicator />
              </Switch>
            </div>
          ))}
        </Card>
      </Section>

      <Separator />

      <Section id={`${id}-danger`} title='Delete account' description='Remove your account and everything it owns from Northwind.'>
        <Card style={{ ...card, gap: 'var(--spacing-8)', '--card-border-color': 'color-mix(in oklch, var(--color-error) 45%, transparent)' } as CSSProperties} aria-labelledby={`${id}-danger`}>
          {deleted && (
            <Alert variant='warning'>
              <AlertIcon />
              <AlertMain>
                <AlertTitle>Deletion scheduled</AlertTitle>
                <AlertContent>Your account will be deleted in 30 days. Sign in before then to cancel.</AlertContent>
              </AlertMain>
            </Alert>
          )}
          <Text size={3} style={{ color: 'var(--label-secondary)' }}>
            Your 12 projects, 340 files and comment history are deleted permanently. Workspaces you own are transferred to another admin or closed.
          </Text>
          <Dialog onChangeOpen={() => setConfirmation('')}>
            <DialogTrigger asChild>
              <Button size='xs' disabled={deleted} style={{ ...destructive, alignSelf: 'flex-start' }}>Delete account</Button>
            </DialogTrigger>
            <DialogContent aria-labelledby={`${id}-dialog-title`} aria-describedby={`${id}-dialog-description`}>
              <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
                <Heading id={`${id}-dialog-title`} as='h2' size={4} fontWeight={600}>Delete your account?</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}-dialog-description`} size={3} style={{ display: 'block', marginBlockStart: 'var(--spacing-4)', color: 'var(--label-secondary)' }}>
                You have 30 days to change your mind. After that, your data can’t be recovered.
              </Text>
              <form method='dialog' onSubmit={deleteAccount} style={{ display: 'grid', gap: 'var(--spacing-3)', marginBlockStart: 'var(--spacing-10)' }}>
                <Label htmlFor={`${id}-confirm`}>Type your email to confirm</Label>
                <TextField>
                  <TextFieldInput
                    id={`${id}-confirm`}
                    value={confirmation}
                    autoComplete='off'
                    spellCheck={false}
                    placeholder={ACCOUNT_EMAIL}
                    onChange={(event) => setConfirmation(event.currentTarget.value)}
                  />
                </TextField>
                <div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-9)' }}>
                  <DialogClose asChild>
                    <Button size='xs' type='button' variant='secondary' appearance='ghost'>Cancel</Button>
                  </DialogClose>
                  <Button size='xs' type='submit' disabled={confirmation !== ACCOUNT_EMAIL} style={confirmation === ACCOUNT_EMAIL ? destructive : undefined}>
                    Delete account
                  </Button>
                </div>
              </form>
            </DialogContent>
          </Dialog>
        </Card>
      </Section>
    </div>
  );
}

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