Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#602] [모임 생성] 퍼널 progress bar, stepper 구현 #607

Merged
merged 15 commits into from
Jun 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
};

Comment on lines +5 to +24
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comment;

!!!! 👍 👍 👍
HoC 패턴은 처음보는데 재사용성 측면에서 굉장히 좋은것같네요!! 💯

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
Loading