From bcbdc67f8e0dc3035e3dfdea5b65a17bface2a9a Mon Sep 17 00:00:00 2001 From: kyuran kim <57716832+gxxrxn@users.noreply.github.com> Date: Sun, 2 Jun 2024 22:55:04 +0900 Subject: [PATCH] =?UTF-8?q?[#602]=20[=EB=AA=A8=EC=9E=84=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1]=20=ED=8D=BC=EB=84=90=20progress=20bar,=20stepper=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#607)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 애니메이션 제거 --- next.config.js | 2 +- public/icons/check-stroke.svg | 3 + public/icons/check.svg | 2 +- public/icons/index.ts | 1 + src/hocs/withScrollLockOnFocus.tsx | 25 ++++ src/hooks/useRemoveVerticalScroll.ts | 4 +- src/stories/base/ProgressBar.stories.tsx | 16 +++ src/stories/base/Stepper.stories.tsx | 24 ++++ src/v1/base/ProgressBar.tsx | 29 +++++ src/v1/base/Stepper.tsx | 113 ++++++++++++++++++ .../create/CreateBookGroupFunnel.tsx | 21 ++++ .../steps/EnterTitleStep/EnterTitleStep.tsx | 4 + .../fields/JoinPasswordFieldset.tsx | 7 +- .../steps/SetUpDetailStep/SetUpDetailStep.tsx | 12 +- tailwind.config.js | 10 +- 15 files changed, 260 insertions(+), 13 deletions(-) create mode 100644 public/icons/check-stroke.svg create mode 100644 src/hocs/withScrollLockOnFocus.tsx create mode 100644 src/stories/base/ProgressBar.stories.tsx create mode 100644 src/stories/base/Stepper.stories.tsx create mode 100644 src/v1/base/ProgressBar.tsx create mode 100644 src/v1/base/Stepper.tsx diff --git a/next.config.js b/next.config.js index 34e86cc96..f8161e3df 100644 --- a/next.config.js +++ b/next.config.js @@ -43,7 +43,7 @@ const nextConfig = { }, { protocol: 'http', - hostname: 'k.kakaocdn.net', + hostname: '*.kakaocdn.net', port: '', pathname: '/**', }, diff --git a/public/icons/check-stroke.svg b/public/icons/check-stroke.svg new file mode 100644 index 000000000..ed04a458f --- /dev/null +++ b/public/icons/check-stroke.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/check.svg b/public/icons/check.svg index 02c67968e..b8f67399b 100644 --- a/public/icons/check.svg +++ b/public/icons/check.svg @@ -1,3 +1,3 @@ - + diff --git a/public/icons/index.ts b/public/icons/index.ts index fc3858331..6c3027649 100644 --- a/public/icons/index.ts +++ b/public/icons/index.ts @@ -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'; diff --git a/src/hocs/withScrollLockOnFocus.tsx b/src/hocs/withScrollLockOnFocus.tsx new file mode 100644 index 000000000..034462b75 --- /dev/null +++ b/src/hocs/withScrollLockOnFocus.tsx @@ -0,0 +1,25 @@ +import { forwardRef, Ref, useState } from 'react'; + +import useRemoveVerticalScroll from '@/hooks/useRemoveVerticalScroll'; + +const withScrollLockOnFocus =

( + WrappedComponent: React.ComponentType

+) => { + const Component = (props: P, ref: Ref) => { + const [focus, setFocus] = useState(false); + useRemoveVerticalScroll({ enabled: focus }); + + return ( + setFocus(true)} + onBlur={() => setFocus(false)} + ref={ref} + /> + ); + }; + + return forwardRef(Component); +}; + +export default withScrollLockOnFocus; diff --git a/src/hooks/useRemoveVerticalScroll.ts b/src/hooks/useRemoveVerticalScroll.ts index 6f0a6f128..293b5bbb3 100644 --- a/src/hooks/useRemoveVerticalScroll.ts +++ b/src/hooks/useRemoveVerticalScroll.ts @@ -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) => @@ -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]); diff --git a/src/stories/base/ProgressBar.stories.tsx b/src/stories/base/ProgressBar.stories.tsx new file mode 100644 index 000000000..82af95955 --- /dev/null +++ b/src/stories/base/ProgressBar.stories.tsx @@ -0,0 +1,16 @@ +import { Meta, StoryObj } from '@storybook/react'; +import ProgressBar from '@/v1/base/ProgressBar'; + +const meta: Meta = { + title: 'Base/ProgressBar', + component: ProgressBar, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { value: 30 }, +}; diff --git a/src/stories/base/Stepper.stories.tsx b/src/stories/base/Stepper.stories.tsx new file mode 100644 index 000000000..d7bfdcfc5 --- /dev/null +++ b/src/stories/base/Stepper.stories.tsx @@ -0,0 +1,24 @@ +import { Meta, StoryObj } from '@storybook/react'; +import Stepper from '@/v1/base/Stepper'; + +const meta: Meta = { + title: 'Base/Stepper', + component: Stepper, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { activeIndex: 0 }, + + render: args => ( + + + + + + ), +}; diff --git a/src/v1/base/ProgressBar.tsx b/src/v1/base/ProgressBar.tsx new file mode 100644 index 000000000..1c0bc23a2 --- /dev/null +++ b/src/v1/base/ProgressBar.tsx @@ -0,0 +1,29 @@ +/** + * @param value percentage + */ +const ProgressBar = ({ + value, + className, +}: { + value: number; + className?: string; +}) => { + return ( +

+
+
+
+ ); +}; + +export default ProgressBar; diff --git a/src/v1/base/Stepper.tsx b/src/v1/base/Stepper.tsx new file mode 100644 index 000000000..59c83c305 --- /dev/null +++ b/src/v1/base/Stepper.tsx @@ -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( + {} 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 ( +
+ + {stepElements.map((child, index) => ( + + {child} + + ))} +
+ ); +}; + +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 ( +
+ {status === 'complete' ? ( + + ) : status === 'active' ? ( +

+ {labelToRender} +

+ ) : ( +

+ {stepNumberToRender} +

+ )} + {children} +
+ ); +}; + +Stepper.Step = Step; + +export default Stepper; diff --git a/src/v1/bookGroup/create/CreateBookGroupFunnel.tsx b/src/v1/bookGroup/create/CreateBookGroupFunnel.tsx index e89dbd015..2df059d21 100644 --- a/src/v1/bookGroup/create/CreateBookGroupFunnel.tsx +++ b/src/v1/bookGroup/create/CreateBookGroupFunnel.tsx @@ -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, @@ -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(); @@ -111,6 +122,16 @@ const CreateBookGroupFunnel = () => { +
+
+ + {steps.map(({ label }, idx) => { + return ; + })} + +
+
+
diff --git a/src/v1/bookGroup/create/steps/EnterTitleStep/EnterTitleStep.tsx b/src/v1/bookGroup/create/steps/EnterTitleStep/EnterTitleStep.tsx index fa7561708..56a94cddc 100644 --- a/src/v1/bookGroup/create/steps/EnterTitleStep/EnterTitleStep.tsx +++ b/src/v1/bookGroup/create/steps/EnterTitleStep/EnterTitleStep.tsx @@ -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(); + useRemoveVerticalScroll(); + return (
diff --git a/src/v1/bookGroup/create/steps/SelectJoinTypeStep/fields/JoinPasswordFieldset.tsx b/src/v1/bookGroup/create/steps/SelectJoinTypeStep/fields/JoinPasswordFieldset.tsx index 3cefe1a36..9fb7c4094 100644 --- a/src/v1/bookGroup/create/steps/SelectJoinTypeStep/fields/JoinPasswordFieldset.tsx +++ b/src/v1/bookGroup/create/steps/SelectJoinTypeStep/fields/JoinPasswordFieldset.tsx @@ -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; @@ -33,6 +34,8 @@ const JoinPasswordFieldset = ({ ); }; +const ScrollLockInput = withScrollLockOnFocus(Input); + const JoinQuestionField = ({ name }: JoinTypeStepFieldProp) => { const { register, @@ -48,7 +51,7 @@ const JoinQuestionField = ({ name }: JoinTypeStepFieldProp) => { return (