diff --git a/next.config.js b/next.config.js index 34e86cc9..f8161e3d 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 00000000..ed04a458 --- /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 02c67968..b8f67399 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 fc385833..6c302764 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 00000000..034462b7 --- /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 6f0a6f12..293b5bbb 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 00000000..82af9595 --- /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 00000000..d7bfdcfc --- /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 00000000..1c0bc23a --- /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 00000000..59c83c30 --- /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 e89dbd01..2df059d2 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 fa756170..56a94cdd 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 3cefe1a3..9fb7c409 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 (