[FE 워크샵] Stepper 혹은 Funnel

5/21/2024

들어가기 전

손꾸락 프로젝트에서 Stepper를 구현하면서, 도메인은 분리하고 UI 로직만 다시 정리해보고 싶었습니다. 마침 FE Workshop 멤버들과 첫 번째 주제를 Stepper (토스에서는 이를 Funnel, Funnel Pattern 이라 부르더라고요)로 정하여 재구현할 기회가 생겼습니다.
 

1. Stepper가 무엇인가요?

Stepper는 아래와 같이, 주로 사용자가 복잡하거나 많은 양의 양식(form)을 작성해야 할 때 사용하는 UI입니다. 기존 form은 한 화면에서 이름, 주소, 전화번호 등을 입력하고 제출합니다.
하지만 이는 사용자로 하여금 한번에 많은 내용을 입력해야 한다는 부담감을 부여할 수도 있습니다. 또한 필드가 많다보니 특정 필드마다 필요한 입력 조건을 확인하기가 어렵기도 합니다.
Stepper는 양식 제출에 필요한 필드들을 여러 스텝들로 제공하여, 분할 작성할 수 있도록 돕습니다. 또한 해당 필드마다 유효성 검사를 실시하여 사용자에게 즉각적으로 피드백합니다.
💡
⇒ 사용자가 어디에 있는지 알 수 없을 때보다 진행 상황을 알 때 포기율이 낮아질 뿐아니라, 불안감이 감소합니다. 이를 통해 사용자 경험을 향상시키며, 사용자가 각 단계를 충실히 완료하도록 유도하여, 데이터 수집의 정확도를 높일 수 있습니다
 
notion image
 
notion image
notion image
 
notion image
notion image

2. Stepper의 구성 요소

  1. Step Indicators
    1. 각 단계를 나타내는 번호로, 현재 단계와 앞으로 남은 단계를 시각적으로 구분합니다. 현재 단계는 시각적으로 강조하여 표시할 수 있습니다. 또한 완료한 단계는 체크 아이콘 등을 활용할 수도 있습니다.
  1. Step Titles
    1. 각 단계의 제목이나 설명을 표시하여 사용자가 현재 단계에서 해야 할 일을 명확하게 이해할 수 있도록 돕습니다.
  1. Progress Bar
    1. 전체 과정에서 현재 진행 상황을 시각적으로 표시합니다. 사용자는 자신이 얼마나 진행했는지 한눈에 파악할 수 있습니다.
  1. Navigation Buttons - 이전/다음 버튼
    1. 사용자가 이전 단계로 돌아가거나 다음 단계로 이동할 수 있도록 돕습니다.
  1. Step Content Area
    1. 각 단계에서 필요한 입력 필드, 내용에 해당합니다. 단계별로 다른 내용을 표시하며, 사용자는 여기서 필요한 작업을 수행합니다.
  1. Validation Messages
    1. 각 단계에서 필요한 입력값에 대해 유효성 검사를 진행하고, 유효하지 않을 때 표시되는 오류 메시지입니다.
  1. Additional Information
    1. 각 단계에서 사용자에게 추가로 필요한 도움말, 정보를 제공합니다
  1. Final Step Confirmation and Submission
    1. 모든 작성 단계를 완료한 후 사용자가 자신이 입력한 내용을 최종 확인하고, 제출하는 영역입니다. 제출 버튼이나 확인 메시지가 포함됩니다.
 

3. Stepper 훑어보기

1) Stepper Page

  • 역할: Step Content에 해당하는 Form, Stepper와 Preview를 관리합니다.
    • 어떤 스텝에서든 작성을 취소할 수 있는 CloseStepButton도 제공합니다
const StepperPage = () => { const { isActive: isDone } = useToggle(); return ( <div className='relative min-h-screen'> <CloseStepButton /> {!isDone && <Stepper />} {isDone && <FormPreivew />} </div> ); };
 

2) Stepper

  • 역할 1) 현재 CurrentStep을 추출하여 자식 Step에게 제공합니다. 2) Step간 이전/ 다음으로 이동할 수 있습니다. 3) 작성 완료시 Step을 종료하고, Preview로 이동합니다
const Stepper = () => { const { fields } = useStepForm(); const { total, current, updateCurrent } = useCurrentStep(); const { order, fields: inputFields } = current; const { setIsActive: setIsDone } = useToggle(); const goToNextStep = () => { updateCurrent(order + 1); }; const goToPrevStep = () => { updateCurrent(order - 1); }; const goToPreview = () => { setIsDone((prev) => !prev); }; return ( <div className='pt-4'> <Step current={current} total={total} /> <NavigationButtons order={order} total={total} onNext={goToNextStep} onPrev={goToPrevStep} onDone={goToPreview} isNextDisabled={isNextButtonDisabled(inputFields, fields)} /> </div> ); };
 

3) Step

  • 역할: 현재 스텝에 대한 Content Area입니다. 사용자는 이 컴포넌트에서 작성을 수행합니다 1) Progress Bar를 활용하여 전체 과정 중 현재 단계의 위치를 제공합니다. 또한 Title을 추가하여 현 단계에서 수행해야할 작업을 명확히 알려줍니다 2) Description은 title 보다 구체적으로 수행 사항을 설명해줍니다. 3) Field 입력해야 할 필드에 대한 label, value, validationRule, invalidText 등을 제공합니다.
interface Props { current: Step; total: number; } const Step = ({ current: { title, order, description, fields }, total, }: Props) => { const { fields: formFields, setFields } = useStepForm(); const updateFormField = (e: ChangeEvent<HTMLInputElement>) => { setFields((prev) => { return { ...prev, [e.target.name]: e.target.value }; }); }; return ( <div> <div className='relative pt-4'> <ProgressBar title={title} current={order} total={total} /> </div> <div className='px-4 pt-10'> <Description description={description} current={order} total={total} className='mb-12' /> <div className='flex flex-wrap gap-10'> {fields.map( ( { label, placeholder, name, type, validationRule, invalidText }, idx ) => ( <Field key={idx} label={label} placeholder={placeholder} name={name} type={type} value={formFields[name] ?? ''} onChange={updateFormField} validationRule={validationRule} invalidText={invalidText} /> ) )} </div> </div> </div> ); };
 

4) Field

  • 역할 1) 부모로 부터 받은 onChange를 수행합니다. 2) input의 입력값이 change함에 따라 validationRule을 수행합니다. 3) 유효하지 않을 경우 invalidText를 보여줍니다.
interface Props extends ClassNameProps { label: string; placeholder: string; name: string; type: HTMLInputTypeAttribute; value: any; onChange: (e: ChangeEvent<HTMLInputElement>) => void; validationRule: (value: any) => boolean; invalidText: string; } const Field = ({ className,label,placeholder,name,type,value,onChange, validationRule, invalidText,}: Props) => { const [isValid, setIsValid] = useState<boolean | 'pending'>('pending'); const handleChange = (e: ChangeEvent<HTMLInputElement>) => { onChange(e); if (!validationRule(e.target.value)) { setIsValid(false); return; } setIsValid(true); }; return ( <div className={`${className} w-full flex flex-wrap `}> <label htmlFor={name} className='text-sm text-black'> {label} </label> <input className={`block p-2 w-full text-xl font-medium placeholder-grey200 text-black border-b ${ isValid === false ? 'border-red' : 'border-grey200' }`} id={name} name={name} type={type} value={value} placeholder={placeholder} onChange={handleChange} /> {isValid === false && ( <p className='text-xs text-red mt-1'>{invalidText}</p> )} </div> ); };
 

4. 상태관리 - Step과 Form

주요한 상태는 2가지입니다. 스텝의 순서, 이름, 설명, 필드, 유효성 검사 등으로 구성된 Step과 사용자가 제출해야하는 항목으로 구성된 Form입니다.
각각의 초기값을 보면 어떤 값인지 쉽게 이해할 수 있습니다.
// Stepper는 Step[]을 사용합니다 const Step= { order: 2, isActive: false, title: '전화번호 입력', description: '전화번호를 입력해주세요', fields: [ { label: '전화번호', placeholder: '전화번호를 입력하세요', name: 'phone_number', type: 'text', validationRule: validatePhoneNumber, invalidText: '01012345678 과 같이 입력해주세요', }, ], } // Form은 input의 name에 해당되는 key와 value를 가진 객체 const Form = { first_name: '', last_name: '', phone_number: '', identification_number: '', address: '', };

1) useCurrentStep

Stepper는 현재 활성화된(isActive) 스텝만을 보여주어야 합니다. 또한 prev, next 버튼을 활용하여 이동도 가능해야 합니다.
이를 제공하기 위해서 current step을 제공하는 훅을 만들었습니다. 해당 훅은 전체 스텝의 수(total), 활성화된 스텝(current), 스텝을 이동할 때 필요한 updateCurrent 함수를 리턴합니다.
import INITIAL_STEPPER from '../const/intial_stepper'; const useCurrentStep = () => { const [steps, setSteps] = useState(INITIAL_STEPPER); const updateCurrent = (newOrder: number) => { const newSteps: Step[] = steps.map((step) => { if (step.isActive) { return { ...step, isActive: false }; } else if (step.order === newOrder) { return { ...step, isActive: true }; } else { return step; } }); setSteps(newSteps); }; const current = steps.find((step) => step.isActive)!; return { total: steps.length, current, updateCurrent, }; };
// useCurrentStep 사용부 const { total, current, updateCurrent } = useCurrentStep(); const Stepper = () => { (...) const { order } = current; (...) return ( <div className='pt-4'> <Step current={current} total={total} /> <NavigationButtons order={order} total={total} (...) /> </div> );

2) useStepForm

form data는 폼을 작성하는 Stepper와 Preview 등 여러 컴포넌트에서 사용됩니다.
Stepper에서 사용자가 입력한 필드 값은 필드 내부에서 관리되지 않습니다. onChage 이벤트가 일어날 때마다 event.target.value는 StepFormProvider의 fields에 저장됩니다. (Field 컴포넌트는 오직 유효성 검사를 진행하고, valid 여부만 관리합니다.)
 
1) Field 컴포넌트는 props로 전달받은 onChange 핸들러에 이벤트 객체만 넘겨주고, 자신이 해야할 유효성 검사를 진행합니다.
//_component/stepper/widget/step/ui/step-form-field.tsx const handleChange = (e: ChangeEvent<HTMLInputElement>) => { onChange(e); if (!validationRule(e.target.value)) { setIsValid(false); return; } setIsValid(true); };
2) 외부에서 전달하는 함수는 updateFormField 로서, stepForm 프로바이더에서 제공받은 함수입니다.
const { setFields } = useStepForm(); const updateFormField = (e: ChangeEvent<HTMLInputElement>) => { setFields((prev) => { return { ...prev, [e.target.name]: e.target.value }; }); };
 

5. Stepper 관리자 Navigation Buttons

3번 목차, Stepper 훑어보기에서 중요한 UI가 빠져있었습니다. 알고 계셨나요?
미리 적지 않은 이유는 네비게이션 버튼이 Step과 Form, 2가지의 상태를 모두 사용하는 복잡한 녀석이기 때문입니다. (정확히 작성 완료 값인 IsDone도 포함하면 3가지네요!)
Step에서 사용하는 버튼의 종류는 3가지입니다. 이전, 다음, 작성 완료입니다. 또한 step의 order와 total 값을 단순 비교하여 조건부 렌더링이 됩니다.
//사용부 <NavigationButtons order={order} total={total} onNext={goToNextStep} onPrev={goToPrevStep} onDone={goToPreview} isNextDisabled={isNextButtonDisabled(inputFields, fields)} />
//구현부 const NavigationButtons = ({order,total,onNext,onPrev,onDone,isNextDisabled,}: Props) => { return ( <div> {order > 1 && <PrevButton onClick={onPrev} />} {order < total && ( <Button disabled={isNextDisabled} onClick={onNext}> 다음 </Button> )} {order === total && ( <Button disabled={isNextDisabled} onClick={onDone}> 작성 완료 </Button> )} </div> ); };

1) disabled를 컨트롤하는 Form

이전 버튼과 달리, 다음 버튼은 언제든 활성화되어 있으면 안됩니다. 사용자가 입력한 값이 해당 필드가 시행하는 유효성 검사에 통과해야만 활성화되어야 합니다. 따라서 disabled는
  • 모든 필드를 순회하면서 (inputFields.every())
  • field.name을 활용해서 formDataFields에 접근합니다. 이 값이
  • field.validationRule()에서 전부 false를 반환해야만
활성화되는 것이죠!
import { StepForm } from '@/app/_provider/stepper/step-form-provider'; import { Field } from '../type'; const isNextButtonDisabled = ( inputFields: Field[], formDataFields: Partial<StepForm> ) => { return !inputFields.every((field) => field.validationRule(formDataFields[field.name]) ); };

2) onClick을 사용하는 Step (+ IsDone)

const { total, current, updateCurrent } = useCurrentStep(); const { order } = current; const { setIsActive: setIsDone } = useToggle(); const goToNextStep = () => { updateCurrent(order + 1); }; const goToPrevStep = () => { updateCurrent(order - 1); }; const goToPreview = () => { setIsDone((prev) => !prev); };
©JIYOUNG CHOI, All rights reserved