Skip to content

Commit

Permalink
[#602] [모임 생성] 퍼널 progress bar, stepper 구현 (#607)
Browse files Browse the repository at this point in the history
* feat: ProgressBar 컴포넌트 구현

- 스토리북 작성

* refactor: 모임 생성 > 상세 정보 스텝에 heading 문구 추가

* feat: stroke 조절이 가능한 check icon 추가

* feat: Stepper 컴포넌트 구현

* feat: Stepper 스토리북 작성

* feat: 모임 생성 퍼널에 stepper 적용

* fix: stepper 애니메이션 수정, 라벨 추가

* fix: useRemoveVerticalScroll hook enabled option이 없는 경우 바로 scroll 제거하도록 수정

* fix: 모임 생성 퍼널 > 이름 작성 스텝에서 scroll 제거

* feat: focus일 때 scroll을 제거하는 withScrollLockOnFocus hoc 구현

* feat: 모임 생성 퍼널 > 모임 정보 설정 스텝에 input, textarea focus 시 scroll 제거 기능 구현

- withScrollLockOnFoucs hoc 적용

* feat: 모임 생성 퍼널 > 가입 유형 스텝 input focus시 scroll 제거

* refactor: stepper 컴포넌트 첫 스텝에서 opacity animation 제거

* fix: nextconfig next image kakaocdn hostname *로 수정

* fix: label이 없는 경우 width 애니메이션 제거
  • Loading branch information
gxxrxn committed Jun 17, 2024
1 parent f044cd2 commit bcbdc67
Show file tree
Hide file tree
Showing 15 changed files with 260 additions and 13 deletions.
2 changes: 1 addition & 1 deletion next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const nextConfig = {
},
{
protocol: 'http',
hostname: 'k.kakaocdn.net',
hostname: '*.kakaocdn.net',
port: '',
pathname: '/**',
},
Expand Down
3 changes: 3 additions & 0 deletions public/icons/check-stroke.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion public/icons/check.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions public/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export { default as IconErrorExclamation } from './error-with-exclamation.svg';
export { default as IconAvatar } from './avatar.svg';
export { default as IconCalendar } from './calendar.svg';
export { default as IconCheck } from './check.svg';
export { default as IconCheckStroke } from './check-stroke.svg';
export { default as IconComments } from './comments.svg';
export { default as IconDelete } from './delete.svg';
export { default as IconMembers } from './members.svg';
Expand Down
25 changes: 25 additions & 0 deletions src/hocs/withScrollLockOnFocus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { forwardRef, Ref, useState } from 'react';

import useRemoveVerticalScroll from '@/hooks/useRemoveVerticalScroll';

const withScrollLockOnFocus = <P extends object>(
WrappedComponent: React.ComponentType<P>
) => {
const Component = (props: P, ref: Ref<HTMLElement>) => {
const [focus, setFocus] = useState(false);
useRemoveVerticalScroll({ enabled: focus });

return (
<WrappedComponent
{...props}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
ref={ref}
/>
);
};

return forwardRef(Component);
};

export default withScrollLockOnFocus;
4 changes: 2 additions & 2 deletions src/hooks/useRemoveVerticalScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef } from 'react';
import { nonPassive } from '@/utils/eventListener';

type Options = {
enabled?: boolean;
enabled: boolean;
};

const getTouchXY = (event: TouchEvent | WheelEvent) =>
Expand All @@ -11,7 +11,7 @@ const getTouchXY = (event: TouchEvent | WheelEvent) =>
: [0, 0];

const useRemoveVerticalScroll = (options?: Options) => {
const enabled = options?.enabled;
const enabled = options ? options.enabled : true;

const touchStartRef = useRef([0, 0]);

Expand Down
16 changes: 16 additions & 0 deletions src/stories/base/ProgressBar.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Meta, StoryObj } from '@storybook/react';
import ProgressBar from '@/v1/base/ProgressBar';

const meta: Meta<typeof ProgressBar> = {
title: 'Base/ProgressBar',
component: ProgressBar,
tags: ['autodocs'],
};

export default meta;

type Story = StoryObj<typeof ProgressBar>;

export const Default: Story = {
args: { value: 30 },
};
24 changes: 24 additions & 0 deletions src/stories/base/Stepper.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Meta, StoryObj } from '@storybook/react';
import Stepper from '@/v1/base/Stepper';

const meta: Meta<typeof Stepper> = {
title: 'Base/Stepper',
component: Stepper,
tags: ['autodocs'],
};

export default meta;

type Story = StoryObj<typeof Stepper>;

export const Default: Story = {
args: { activeIndex: 0 },

render: args => (
<Stepper {...args}>
<Stepper.Step />
<Stepper.Step />
<Stepper.Step />
</Stepper>
),
};
29 changes: 29 additions & 0 deletions src/v1/base/ProgressBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* @param value percentage
*/
const ProgressBar = ({
value,
className,
}: {
value: number;
className?: string;
}) => {
return (
<div
className={
'absolute inset-x-0 h-[0.2rem] w-full overflow-hidden ' + className
}
>
<div className="absolute h-full w-full bg-main-500" />
<div
className="absolute h-full w-full bg-main-900"
style={{
transform: `translateX(-${100 - value}%)`,
transition: 'transform 0.4s ease-in-out',
}}
/>
</div>
);
};

export default ProgressBar;
113 changes: 113 additions & 0 deletions src/v1/base/Stepper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import {
Children,
createContext,
PropsWithChildren,
ReactNode,
useContext,
} from 'react';

import { IconCheckStroke } from '@public/icons';
import ProgressBar from '@/v1/base/ProgressBar';

type StepStatus = 'complete' | 'incomplete' | 'active';

type StepContextValues = {
index: number;
status: StepStatus;
count: number;
};

const StepperContext = createContext<StepContextValues>(
{} as StepContextValues
);

const Stepper = ({
activeIndex,
children,
}: PropsWithChildren<{ activeIndex: number }>) => {
const stepElements = Children.toArray(children);
const stepCount = stepElements.length;

const progressPercent =
activeIndex === 0 ? 0 : Math.ceil((activeIndex / (stepCount - 1)) * 100);

const getStepStatus = (step: number): StepStatus => {
if (step < activeIndex) return 'complete';
if (step > activeIndex) return 'incomplete';
return 'active';
};

return (
<div className="relative z-[1] flex w-full items-center justify-between">
<ProgressBar value={progressPercent} className="-z-[1]" />
{stepElements.map((child, index) => (
<StepperContext.Provider
key={index}
value={{ index, status: getStepStatus(index), count: stepCount }}
>
{child}
</StepperContext.Provider>
))}
</div>
);
};

const getStepClasses = (status: StepStatus, label?: string) => {
switch (status) {
case 'complete':
return 'bg-main-900';
// TODO: label text width 계산 로직 추가
case 'active':
return `bg-main-900 ${label ? 'w-[7.4rem]' : ''}`;
case 'incomplete':
default:
return 'bg-main-500';
}
};

const Step = ({
label,
children,
}: {
label?: string;
children?: ReactNode;
}) => {
const { status, index } = useContext(StepperContext);

const statusClasses = getStepClasses(status, label);
const labelPositionClass = label
? 'self-baseline px-[1.2rem]'
: 'self-center';

// 첫번째 스텝이 아니고, 라벨 text가 있는 경우만 opacity transition 적용
const activeAnimationClasses =
index !== 0 && label ? 'opacity-0 animate-stepper-transition' : 'opacity-1';

const stepNumberToRender = index + 1;
const labelToRender = label ? label : stepNumberToRender;

return (
<div
className={`relative flex h-[3rem] w-[3rem] shrink-0 flex-col items-center justify-center rounded-full duration-500 ${statusClasses} overflow-hidden`}
>
{status === 'complete' ? (
<IconCheckStroke className="h-auto w-[1rem]" />
) : status === 'active' ? (
<p
className={`relative whitespace-nowrap text-white font-body2-bold ${activeAnimationClasses} ${labelPositionClass}`}
>
{labelToRender}
</p>
) : (
<p className="relative text-white font-body2-bold">
{stepNumberToRender}
</p>
)}
{children}
</div>
);
};

Stepper.Step = Step;

export default Stepper;
21 changes: 21 additions & 0 deletions src/v1/bookGroup/create/CreateBookGroupFunnel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { SERVICE_ERROR_MESSAGE } from '@/constants';

import { IconArrowLeft } from '@public/icons';
import TopNavigation from '@/v1/base/TopNavigation';
import Stepper from '@/v1/base/Stepper';
import {
EnterTitleStep,
SelectBookStep,
Expand All @@ -28,11 +29,21 @@ const FUNNEL_STEPS = [
'SelectJoinType',
] as const;

const steps = [
{ label: '도서선택' },
{ label: '모임이름' },
{ label: '모임정보' },
{ label: '가입유형' },
];

const CreateBookGroupFunnel = () => {
const router = useRouter();
const [Funnel, setStep, currentStep] = useFunnel(FUNNEL_STEPS, {
initialStep: 'SelectBook',
});
const stepIndex = FUNNEL_STEPS.indexOf(currentStep);
const activeStep = stepIndex !== -1 ? stepIndex : 0;

const { show: showToast } = useToast();
const { mutate } = useCreateBookGroupMutation();

Expand Down Expand Up @@ -111,6 +122,16 @@ const CreateBookGroupFunnel = () => {
</TopNavigation.LeftItem>
</TopNavigation>

<div className="sticky top-[5.4rem] z-10 -ml-[2rem] w-[calc(100%+4rem)] bg-white px-[2rem] pb-[3rem] pt-[1rem]">
<div className="relative left-1/2 w-[98%] -translate-x-1/2 ">
<Stepper activeIndex={activeStep}>
{steps.map(({ label }, idx) => {
return <Stepper.Step key={idx} label={label} />;
})}
</Stepper>
</div>
</div>

<form>
<Funnel>
<Funnel.Step name="SelectBook">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@ import { useFormContext } from 'react-hook-form';
import type { MoveFunnelStepProps } from '@/v1/base/Funnel';
import type { EnterTitleStepFormValues } from '../../types';

import useRemoveVerticalScroll from '@/hooks/useRemoveVerticalScroll';

import BottomActionButton from '@/v1/base/BottomActionButton';
import { TitleField } from './fields';

const EnterTitleStep = ({ onNextStep }: MoveFunnelStepProps) => {
const { handleSubmit } = useFormContext<EnterTitleStepFormValues>();

useRemoveVerticalScroll();

return (
<article>
<section className="flex flex-col gap-[1.5rem]">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
import ErrorMessage from '@/v1/base/ErrorMessage';
import Input from '@/v1/base/Input';
import InputLength from '@/v1/base/InputLength';
import withScrollLockOnFocus from '@/hocs/withScrollLockOnFocus';

type JoinPasswordFieldsetProps = {
joinTypeFieldName: JoinTypeStepFieldName;
Expand All @@ -33,6 +34,8 @@ const JoinPasswordFieldset = ({
);
};

const ScrollLockInput = withScrollLockOnFocus(Input);

const JoinQuestionField = ({ name }: JoinTypeStepFieldProp) => {
const {
register,
Expand All @@ -48,7 +51,7 @@ const JoinQuestionField = ({ name }: JoinTypeStepFieldProp) => {
return (
<label className="flex flex-col gap-[0.5rem]">
<p>가입 문제</p>
<Input
<ScrollLockInput
placeholder="모임에 가입하기 위한 적절한 문제를 작성해주세요"
{...register(name, {
required: '1 ~ 30글자의 가입 문제가 필요해요',
Expand Down Expand Up @@ -86,7 +89,7 @@ const JoinAnswerField = ({ name }: JoinTypeStepFieldProp) => {
return (
<label className="flex flex-col gap-[0.5rem]">
<p>정답</p>
<Input
<ScrollLockInput
placeholder="띄어쓰기 없이 정답을 작성해주세요"
{...register(name, {
required: '띄어쓰기 없이 10글자 이하의 정답이 필요해요',
Expand Down
Loading

0 comments on commit bcbdc67

Please sign in to comment.