Wizard

패키지 네임스페이스가 아니라 레시피입니다. Wizard는 긴 폼 하나를 여러 단계로 나눕니다. Steps 목록은 사람이 어디까지 왔는지 알려 주고, 각 단계는 자기 규칙을 가진 Form이며, 마지막 단계가 모든 답을 보냅니다. 어떤 단계가 있고 각 단계가 무엇을 묻는지는 제품이 정합니다. Wizard.* export는 없습니다.

이 페이지의 내용

단계를 차례로 지나가 보세요. 임시 서버가 아는 ben@example.com 주소와 Todae 워크스페이스 이름을 쓰면 마지막 단계에서 난 오류가 그것을 묻는 단계로 돌아오는 것을 볼 수 있습니다.

  1. In progress: Account
  2. Not started: Workspace
  3. Not started: Review

Step 1 of 3Account

단계마다 폼 하나

단계마다 Form.Root를 하나씩 주세요. 그러면 사람이 Next를 누를 때 브라우저가 그 단계의 컨트롤만 검사하고, 문제가 있는 단계는 여느 폼처럼 첫 번째 잘못된 컨트롤에 포커스를 둡니다. 답은 Wizard 전체에 걸친 상태 하나에 두세요. 그래야 Back으로 돌아가도 입력한 내용이 남고, 마지막 단계에서 전부 보여 줄 수 있습니다.

모든 단계의 폼을 마운트해 두고, 보이지 않는 폼은 hidden으로 숨기세요. 숨긴 폼은 탭 순서와 접근성 트리에서 빠지지만 컨트롤은 그대로 가지고 있습니다. 언마운트된 폼은 응답을 기다리던 것을 잊는데, 마지막 단계에는 그 기다림이 필요합니다. 그 오류를 다루는 절에서 이유를 봅니다. Form.Root 하나로 모든 단계를 감싸지 마세요. 폼은 한 번 제출된 뒤에는 가진 모든 컨트롤의 문제를 보여 주므로, 다음 단계가 열리자마자 모든 필드가 잘못된 것으로 표시됩니다.

import { Button, ErrorMessage, Field, Form, Label, Stack, Text } from '@tounsoo/todae';

<Form.Root
  ref={(element) => {
    forms.current[0] = element;
  }}
  hidden={step !== 0}
  aria-labelledby={headingIds[0]}
  errors={0 <= reached ? pick(reply, STEPS[0].fields) : {}}
  onSubmit={next(0)}
>
  <Stack.Root gap="normal">
    <Text.Root
      as="h2"
      id={headingIds[0]}
      ref={(element) => {
        headings.current[0] = element;
      }}
      tabIndex={-1}
    >
      <Text.Root as="span" variant="label" style={{ display: 'block' }}>
        Step 1 of 3
      </Text.Root>
      Account
    </Text.Root>
    <Field.Group>
      <Label.Root htmlFor={emailId}>Work email</Label.Root>
      <Field.Root id={emailId} name="email">
        <Field.TextInput type="email" required value={answers.email} onValueChange={set('email')} />
      </Field.Root>
      <ErrorMessage.Root />
    </Field.Group>
    <Button.Root type="submit">Next</Button.Root>
  </Stack.Root>
</Form.Root>

Next는 단계의 제출 버튼이므로 필드에서 Enter를 눌러도 다음으로 넘어갑니다. Back은 type="button"입니다. 단계를 검사하지 않고 돌아가며, 입력한 답은 남습니다.

단계마다 포커스

Next와 Back을 누르면 새 단계의 제목으로 포커스를 옮기세요. 누른 버튼은 이제 숨겨졌으므로 그대로 두면 포커스가 페이지로 떨어지고, 제목은 스크린 리더에 지금 어느 단계인지 알려 줍니다. 제목에 tabIndex={-1}을 주어 코드로는 포커스할 수 있지만 Tab으로는 건너뛰게 하고, 포커스된 제목이 스크린 리더가 읽는 내용이므로 단계의 위치를 제목 안에 넣으세요. 단계가 보인 뒤, 포커스가 아직 그 단계의 폼 안에 없을 때만 제목에 포커스하세요. 응답을 받은 폼은 직접 컨트롤로 포커스를 옮기며, 스크린 리더가 제목을 먼저 읽어서는 안 됩니다. Form.Root는 Form에서 말하듯 React가 커밋에서 제어를 돌려주기 전에 포커스를 옮기므로, 레이아웃 이펙트에서 건 마이크로태스크에서 확인하세요.

import { useLayoutEffect, useRef, useState, type SubmitEvent } from 'react';

const [step, setStep] = useState(0);
// The furthest step shown since the last reply: each gets its errors from then until its own Next.
const [reached, setReached] = useState(-1);
const forms = useRef<(HTMLFormElement | null)[]>([]);
const headings = useRef<(HTMLElement | null)[]>([]);
// Set once the person moves, so the first render doesn't take focus.
const moved = useRef(false);

useLayoutEffect(() => {
  if (!moved.current) return;
  // Form.Root moves focus for a reply in an update from its own layout effect, which React renders
  // before this microtask, so a control it focused keeps focus.
  queueMicrotask(() => {
    if (!forms.current[step]?.contains(document.activeElement)) headings.current[step]?.focus();
  });
}, [step]);

const go = (index: number) => {
  moved.current = true;
  setReached((current) => Math.max(current, index));
  setFailed(null);
  setStep(index);
};

// Form.Root calls onSubmit once the step's controls pass its check.
const next = (index: number) => (event: SubmitEvent<HTMLFormElement>) => {
  event.preventDefault();
  // Moving on sends the step again: drop its errors, as Form.Root asks.
  setReply((current) => pick(current, STEPS[index].fields, false));
  go(index + 1);
};

단계는 Steps.Root variant="progress"로 표시하세요. 현재 단계 앞은 done, 뒤는 pending입니다. progress 변형은 현재 단계를 멈춘 표시로 그리고 끝난 단계의 텍스트를 그대로 둡니다. 기본 plan 변형은 스피너를 돌리고 취소선을 긋습니다. 현재 단계는 aria-current="step"을 받고, 각 단계는 상태 단어를 먼저 읽습니다.

import { Steps, type StepsStatus } from '@tounsoo/todae';

function statusOf(index: number, step: number): StepsStatus {
  if (index < step) return 'done';
  return index === step ? 'current' : 'pending';
}

<Steps.Root variant="progress" aria-label="Sign-up steps">
  {STEPS.map((each, index) => (
    <Steps.Item key={each.title} status={statusOf(index, step)}>
      {each.title}
    </Steps.Item>
  ))}
</Steps.Root>

마지막 단계의 오류

마지막 단계는 답을 DataList에 나열하고 보냅니다. 서버는 그래도 앞 단계의 답을 거절할 수 있습니다. 이미 쓰이는 주소가 그 예입니다. 응답의 오류와, 어느 단계가 어느 컨트롤을 가지는지 적은 표를 두고, 오류가 있는 첫 단계를 보여 주세요. 단계에는 응답 뒤 처음 보일 때부터 자기 Next까지 자기 항목을 주고, 그 전에는 주지 마세요. 숨긴 폼은 포커스가 닿지 않는 곳에서 응답을 받기 때문입니다. 어느 단계도 가지지 않은 컨트롤에 대한 오류가 든 응답은 그 오류를 보여 줄 곳이 없으므로, 성공이 아니라 실패한 요청으로 다루세요. 요청이 진행되는 동안에는 Back을 막아 응답이 보낸 답으로 돌아오게 하고, 실패한 요청은 버튼 옆의 Alert로 보여 주세요.

import type { FormErrors } from '@tounsoo/todae';

type Name = 'name' | 'email' | 'workspace' | 'plan';

const STEPS: readonly { title: string; fields: readonly Name[] }[] = [
  { title: 'Account', fields: ['name', 'email'] },
  { title: 'Workspace', fields: ['workspace', 'plan'] },
  { title: 'Review', fields: [] },
];

// The entries of a reply that belong, or don't belong, to a step's controls.
function pick(reply: FormErrors, fields: readonly string[], keep = true): FormErrors {
  return Object.fromEntries(Object.entries(reply).filter(([name]) => fields.includes(name) === keep));
}

const create = async (event: SubmitEvent<HTMLFormElement>) => {
  event.preventDefault();
  if (sending) return;
  // Hold Back and Create with aria-disabled while this is true.
  setSending(true);
  setFailed(null);
  setReply({});
  let errors: FormErrors;
  try {
    errors = await createWorkspace(answers);
  } catch {
    // Shown in an Alert.Root tone="critical" beside the button, which is announced.
    return setFailed("The workspace wasn't created. Try again.");
  } finally {
    setSending(false);
  }
  const named = Object.entries(errors).filter(([, text]) => text);
  if (named.length === 0) return setCreated(true);
  // An error for a control no step holds can't be shown where it belongs: the request failed.
  if (named.some(([name]) => !STEPS.some((each) => each.fields.includes(name as Name)))) {
    // Trying again won't change the answer, so don't ask for it.
    return setFailed("The workspace wasn't created, for a reason this form can't show. Contact support.");
  }
  const first = STEPS.findIndex((each) => each.fields.some((name) => errors[name]));
  setReply(errors);
  // A new reply: no step has its errors yet.
  setReached(-1);
  go(first);
};

그 단계의 폼은 자기 Next로 보낸 뒤 아직 응답을 기다리고 있습니다. 보낸 폼은 사람이 고치거나, 리셋하거나, 응답이 올 때까지 기다리기 때문입니다. 그래서 errors가 오면 Form에서 설명하듯 그것을 응답으로 받습니다. 메시지를 보여 주고 컨트롤로 포커스를 옮기며, 스크린 리더는 컨트롤을 오류와 함께 읽습니다. 단계를 마운트해 두는 이유가 이것입니다. 한 번 보인 단계는 숨겨져도 자기 항목을 유지하므로, 사람이 고친 오류는 앞뒤로 오가도 계속 숨겨집니다. 사람이 그 단계를 고치고 Next를 누르면 오류가 있는 다음 단계가 보이고, 그 폼도 같은 방식으로 오류를 받습니다.

접근성을 지키는 조합

Todae가 맡는 것: Form.Root는 Next를 누를 때 단계의 컨트롤을 검사하고, 각 문제를 페이지의 언어로 쓰고, 첫 번째 문제로 포커스를 옮깁니다. 서버 오류를 컨트롤에 aria-invalid와 aria-describedby로 보여 주고, 보낸 폼은 응답이 컨트롤을 가리키면 그 컨트롤로 포커스를 옮깁니다. Steps.Root는 항목이 상태 단어를 먼저 읽는 순서 있는 목록이며, 현재 단계에 aria-current="step"을 줍니다. aria-disabled인 Button.Root는 마지막 단계가 보내는 동안 포커스를 유지하고 클릭을 무시합니다.

직접 제공할 것: 단계마다 폼 하나. 모두 마운트하고 보이지 않는 폼에는 hidden을 줍니다. Next와 Back에서 보여 주는 단계의 제목으로 옮기는 포커스. 단, 그 폼이 컨트롤로 포커스를 옮겼다면 옮기지 않습니다. 끝에서는 결과를 알리는 제목으로 옮기는 포커스. 마지막 단계가 보내는 동안 막아 둔 Back과 Create, 그리고 실패한 요청을 알리는 메시지. 제목 안의 단계 위치. 서버 오류가 알맞은 폼에 닿도록 어느 단계가 어느 컨트롤을 가지는지 적은 표. 단계 목록의 이름과 제품의 언어로 쓴 레이블.

주의할 점: 모든 단계를 감싼 Form.Root 하나. 단계가 보이자마자 잘못된 것으로 표시됩니다. 보이지 않는 단계를 언마운트하는 것. 마지막 단계의 서버 오류가 컨트롤에 보이기는 하지만 아무도 그리로 포커스를 옮기지 않습니다. Next 뒤에 숨겨진 버튼에 남은 포커스. 페이지로 떨어집니다. 단계를 정한 직후, 단계가 보이기 전에 제목에 포커스하는 것. 아무 일도 일어나지 않습니다. 마지막 단계가 보내는 동안 사람이 돌아가 고치게 두는 것. 고치면 폼의 기다림이 끝나므로 응답이 오류를 보여 주기는 하지만 포커스를 옮기지 않고, 그 오류는 사람이 이미 바꾼 답에 대한 것일 수 있습니다. Stack.Root처럼 스타일이 있는 래퍼에 display: none으로 단계를 숨기는 것. 래퍼의 display가 hidden을 이기므로 hidden은 폼에 주세요. 요청이 진행되는 동안 비활성화한 Next나 보내기 버튼. 포커스가 떨어지므로 aria-disabled를 쓰세요. 단계마다 URL이 있는 경우. 브라우저의 뒤로 버튼도 단계 사이를 오가므로, 단계마다 페이지 제목을 바꾸고 포커스도 같은 방식으로 옮기세요.

언제 쓰나요

쓸 때: 가입, 온보딩, 결제처럼 한 번 채우는 긴 폼으로, 단계로 나누면 읽기 쉬워지고 단계마다 따로 검사할 수 있을 때.

쓰지 않을 때: 짧은 폼 → Form 하나. 아무 순서로나 바꾸는 설정 → Fieldset을 늘어놓은 페이지, 또는 구역 사이의 Tabs. 스스로 실행되는 에이전트의 계획 → 기본 plan 변형의 Steps.