Skip to content
Closed
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
25 changes: 25 additions & 0 deletions src/components/input/AccountInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Input from './Input';
import { AccountInputProps } from './types/types';
import styles from './styles/AccountInput.module.css';

/**
* 프로필 페이지용 계정 정보 표시 컴포넌트.
* 이메일과 비밀번호 모두 읽기 전용으로 표시한다.
* @param email 등록된 이메일 주소
* @param children 하단 영역에 렌더링할 요소 (수정하기 버튼 등)
*/
export default function AccountInput({ email, children }: AccountInputProps) {
return (
<div className={styles.container}>
<div className={styles.field}>
<label className={styles.label}>이메일</label>
<Input type="email" value={email} disabled className={styles.readOnly} />
</div>
<div className={styles.field}>
<label className={styles.label}>비밀번호</label>
<Input type="password" value="••••••••" disabled className={styles.readOnly} />
</div>
Comment on lines +14 to +21

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

두 가지 개선점을 제안합니다:

  1. 웹 접근성: 스크린 리더 사용자의 편의를 위해 <label>htmlFor 속성과 <input>id 속성을 연결하여 접근성을 향상시켜야 합니다.
  2. 제어되지 않는 컴포넌트 오류 방지: email prop이 undefined일 경우, React에서 제어되지 않는(uncontrolled) input이 제어되는(controlled) input으로 변경된다는 경고가 발생할 수 있습니다. value={email ?? ''}와 같이 기본값을 제공하여 이를 방지할 수 있습니다.
Suggested change
<div className={styles.field}>
<label className={styles.label}>이메일</label>
<Input type="email" value={email} disabled className={styles.readOnly} />
</div>
<div className={styles.field}>
<label className={styles.label}>비밀번호</label>
<Input type="password" value="••••••••" disabled className={styles.readOnly} />
</div>
<div className={styles.field}>
<label htmlFor="account-email" className={styles.label}>이메일</label>
<Input id="account-email" type="email" value={email ?? ''} disabled className={styles.readOnly} />
</div>
<div className={styles.field}>
<label htmlFor="account-password" className={styles.label}>비밀번호</label>
<Input id="account-password" type="password" value="••••••••" disabled className={styles.readOnly} />
</div>

{children && <div className={styles.buttonArea}>{children}</div>}
</div>
);
}
61 changes: 61 additions & 0 deletions src/components/input/ActionTextArea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
'use client';

import { useCallback, useRef, useState } from 'react';
import Image from 'next/image';
import clsx from 'clsx';
import TextArea from './TextArea';
import { ActionTextAreaProps } from './types/types';
import arrowActive from '@/assets/buttons/arrow/arrowUpActivedButton.svg';
import arrowInactive from '@/assets/buttons/arrow/arrowUpNonActivedButton.svg';
import styles from './styles/ActionTextArea.module.css';

/**
* 전송 버튼이 포함된 텍스트 입력 기본 컴포넌트.
* 입력값이 있으면 전송 버튼이 활성화된다.
* @param onSubmit 전송 버튼 클릭 시 호출되는 콜백
* @param wrapperClassName wrapper div에 적용할 추가 CSS 클래스
* @param className TextArea에 적용할 추가 CSS 클래스
* @param props 네이티브 textarea의 모든 속성
*/
export default function ActionTextArea({
onSubmit,
wrapperClassName,
className,
onChange,
...props
}: ActionTextAreaProps) {
const [hasValue, setHasValue] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);

const autoResize = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
}, []);

return (
<div className={clsx(styles.wrapper, wrapperClassName)}>
<TextArea
ref={textareaRef}
rows={1}
className={clsx(styles.textarea, className)}
onChange={(e) => {
setHasValue(e.target.value.length > 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

현재는 공백만 입력해도 전송 버튼이 활성화됩니다. trim()을 사용하여 문자열의 양 끝 공백을 제거한 후 길이를 확인하면, 의미있는 입력이 있을 때만 버튼이 활성화되도록 개선할 수 있습니다.

Suggested change
setHasValue(e.target.value.length > 0);
setHasValue(e.target.value.trim().length > 0);

autoResize();
onChange?.(e);
}}
{...props}
/>
<button
type="button"
className={styles.action}
onClick={onSubmit}
disabled={!hasValue}
aria-label="전송"
>
<Image src={hasValue ? arrowActive : arrowInactive} alt="" width={24} height={24} />
</button>
</div>
);
}
41 changes: 41 additions & 0 deletions src/components/input/ChangePassword.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import Input from './Input';
import { ChangePasswordProps } from './types/types';
import styles from './styles/ChangePassword.module.css';

/**
* 비밀번호 변경 컴포넌트.
* @param isEditing 편집 모드 여부 (false면 인풋 비활성화, 기본값 false)
* @param newPasswordProps 새 비밀번호 인풋에 전달할 props
* @param confirmPasswordProps 새 비밀번호 확인 인풋에 전달할 props
* @param children 버튼 등 하단 영역에 렌더링할 요소
*/
export default function ChangePassword({
isEditing = false,
newPasswordProps,
confirmPasswordProps,
children,
}: ChangePasswordProps) {
return (
<div className={styles.container}>
<div className={styles.field}>
<label className={styles.label}>새 비밀번호</label>
<Input
type="password"
placeholder="새 비밀번호를 입력해 주세요."
disabled={!isEditing}
{...newPasswordProps}
/>
</div>
<div className={styles.field}>
<label className={styles.label}>새 비밀번호 확인</label>
<Input
type="password"
placeholder="새 비밀번호를 다시 한 번 입력해 주세요."
disabled={!isEditing}
{...confirmPasswordProps}
/>
</div>
<div className={styles.buttonArea}>{children}</div>
</div>
);
}
Comment on lines +1 to +41

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

ChangePassword 컴포넌트를 개선할 몇 가지 제안사항이 있습니다:

  1. PasswordInput 사용: 현재 Input 컴포넌트를 직접 사용하고 있는데, 비밀번호 가시성 토글 기능이 있는 PasswordInput 컴포넌트를 사용하면 사용자 경험이 향상됩니다.
  2. 웹 접근성: <label><input>htmlForid 속성으로 연결하여 스크린 리더 사용자들이 더 쉽게 양식을 이용할 수 있도록 해야 합니다.
import PasswordInput from './PasswordInput';
import { ChangePasswordProps } from './types/types';
import styles from './styles/ChangePassword.module.css';

/**
 * 비밀번호 변경 컴포넌트.
 * @param isEditing 편집 모드 여부 (false면 인풋 비활성화, 기본값 false)
 * @param newPasswordProps 새 비밀번호 인풋에 전달할 props
 * @param confirmPasswordProps 새 비밀번호 확인 인풋에 전달할 props
 * @param children 버튼 등 하단 영역에 렌더링할 요소
 */
export default function ChangePassword({
  isEditing = false,
  newPasswordProps,
  confirmPasswordProps,
  children,
}: ChangePasswordProps) {
  return (
    <div className={styles.container}>
      <div className={styles.field}>
        <label className={styles.label} htmlFor="new-password">새 비밀번호</label>
        <PasswordInput
          id="new-password"
          placeholder="새 비밀번호를 입력해 주세요."
          disabled={!isEditing}
          {...newPasswordProps}
        />
      </div>
      <div className={styles.field}>
        <label className={styles.label} htmlFor="confirm-password">새 비밀번호 확인</label>
        <PasswordInput
          id="confirm-password"
          placeholder="새 비밀번호를 다시 한 번 입력해 주세요."
          disabled={!isEditing}
          {...confirmPasswordProps}
        />
      </div>
      <div className={styles.buttonArea}>{children}</div>
    </div>
  );
}

20 changes: 20 additions & 0 deletions src/components/input/CommentInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import clsx from 'clsx';
import ActionTextArea from './ActionTextArea';
import { CommentInputProps } from './types/types';
import styles from './styles/CommentInput.module.css';

/**
* 댓글 입력 컴포넌트.
* ActionTextArea를 위아래 보더 스타일로 감싼다.
* @param className TextArea에 적용할 추가 CSS 클래스
* @param props ActionTextArea의 모든 속성
*/
export default function CommentInput({ className, ...props }: CommentInputProps) {
return (
<ActionTextArea
wrapperClassName={styles.wrapper}
className={clsx(styles.textarea, className)}
{...props}
/>
);
}
12 changes: 12 additions & 0 deletions src/components/input/Input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import clsx from 'clsx';
import { InputProps } from './types/types';
import styles from './styles/Input.module.css';

/**
* 공통 Input 컴포넌트.
* @param className 추가 CSS 클래스
* @param props 네이티브 input의 모든 속성(placeholder, type, onChange 등)
*/
export default function Input({ className, ...props }: InputProps) {
return <input className={clsx(styles.input, className)} {...props} />;
}
Comment on lines +1 to +12

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

함수 컴포넌트에서 ref를 props로 직접 받아 전달할 수 없습니다. ref를 올바르게 전달하기 위해서는 React.forwardRef를 사용해야 합니다. react-hook-form과 같은 라이브러리에서 ref를 사용하려면 이 수정이 필수적입니다.

Suggested change
import clsx from 'clsx';
import { InputProps } from './types/types';
import styles from './styles/Input.module.css';
/**
* 공통 Input 컴포넌트.
* @param className 추가 CSS 클래스
* @param ref 외부에서 전달하는 ref (react-hook-form )
* @param props 네이티브 input의 모든 속성(placeholder, type, onChange )
*/
export default function Input({ className, ref, ...props }: InputProps) {
return <input ref={ref} className={clsx(styles.input, className)} {...props} />;
}
import { forwardRef } from 'react';
import clsx from 'clsx';
import { InputProps } from './types/types';
import styles from './styles/Input.module.css';
/**
* 공통 Input 컴포넌트.
* @param className 추가 CSS 클래스
* @param props 네이티브 input의 모든 속성(placeholder, type, onChange )
*/
const Input = forwardRef<HTMLInputElement, InputProps>(({ className, ...props }, ref) => {
return <input ref={ref} className={clsx(styles.input, className)} {...props} />;
});
Input.displayName = 'Input';
export default Input;

42 changes: 42 additions & 0 deletions src/components/input/PasswordInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use client';

import { useState } from 'react';
import Image from 'next/image';
import clsx from 'clsx';
import Input from './Input';
import { PasswordInputProps } from './types/types';
import visibilityTrue from '@/assets/icons/visibility/visibillityTrue.svg';
import visibilityFalse from '@/assets/icons/visibility/visibillityFalse.svg';
import styles from './styles/PasswordInput.module.css';

/**
* 비밀번호 Input 컴포넌트.
* @param className 추가 CSS 클래스
* @param props 네이티브 input의 모든 속성(type 제외)
*/
export default function PasswordInput({ className, ...props }: PasswordInputProps) {
const [showPassword, setShowPassword] = useState(false);

return (
<div className={styles.wrapper}>
<Input
type={showPassword ? 'text' : 'password'}
className={clsx(styles.input, className)}
{...props}
/>
<button
type="button"
className={styles.toggleButton}
onClick={() => setShowPassword((prev) => !prev)}
aria-label={showPassword ? '비밀번호 숨기기' : '비밀번호 보기'}
>
<Image
src={showPassword ? visibilityTrue : visibilityFalse}
alt=""
width={24}
height={24}
/>
</button>
</div>
);
}
Comment on lines +1 to +42

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

부모 컴포넌트로부터 ref를 받아 내부 Input 컴포넌트로 전달하려면 PasswordInput 컴포넌트도 React.forwardRef로 감싸야 합니다. 이는 react-hook-form과 같은 라이브러리와의 연동을 위해 중요합니다.

'use client';

import { forwardRef, useState } from 'react';
import Image from 'next/image';
import clsx from 'clsx';
import Input from './Input';
import { PasswordInputProps } from './types/types';
import visibilityTrue from '@/assets/icons/visibility/visibillityTrue.svg';
import visibilityFalse from '@/assets/icons/visibility/visibillityFalse.svg';
import styles from './styles/PasswordInput.module.css';

/**
 * 비밀번호 Input 컴포넌트.
 * @param className 추가 CSS 클래스
 * @param props 네이티브 input의 모든 속성(type 제외)
 */
const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
  ({ className, ...props }, ref) => {
    const [showPassword, setShowPassword] = useState(false);

    return (
      <div className={styles.wrapper}>
        <Input
          ref={ref}
          type={showPassword ? 'text' : 'password'}
          className={clsx(styles.input, className)}
          {...props}
        />
        <button
          type="button"
          className={styles.toggleButton}
          onClick={() => setShowPassword((prev) => !prev)}
          aria-label={showPassword ? '비밀번호 숨기기' : '비밀번호 보기'}
        >
          <Image
            src={showPassword ? visibilityTrue : visibilityFalse}
            alt=""
            width={24}
            height={24}
          />
        </button>
      </div>
    );
  },
);

PasswordInput.displayName = 'PasswordInput';

export default PasswordInput;

12 changes: 12 additions & 0 deletions src/components/input/TextArea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import clsx from 'clsx';
import { TextAreaProps } from './types/types';
import styles from './styles/TextArea.module.css';

/**
* 멀티라인 입력 컴포넌트.
* @param className 추가 CSS 클래스
* @param props 네이티브 textarea의 모든 속성(placeholder, rows, onChange 등)
*/
export default function TextArea({ className, ref, ...props }: TextAreaProps) {
return <textarea ref={ref} className={clsx(styles.textarea, className)} {...props} />;
}
Comment on lines +1 to +12

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

함수 컴포넌트에서 ref를 props로 직접 받아 전달할 수 없습니다. ref를 올바르게 전달하기 위해서는 React.forwardRef를 사용해야 합니다. react-hook-form과 같은 라이브러리에서 ref를 사용하려면 이 수정이 필수적입니다.

Suggested change
import clsx from 'clsx';
import { TextAreaProps } from './types/types';
import styles from './styles/TextArea.module.css';
/**
* 멀티라인 입력 컴포넌트.
* @param className 추가 CSS 클래스
* @param ref 외부에서 전달하는 ref (react-hook-form )
* @param props 네이티브 textarea의 모든 속성(placeholder, rows, onChange )
*/
export default function TextArea({ className, ref, ...props }: TextAreaProps) {
return <textarea ref={ref} className={clsx(styles.textarea, className)} {...props} />;
}
import { forwardRef } from 'react';
import clsx from 'clsx';
import { TextAreaProps } from './types/types';
import styles from './styles/TextArea.module.css';
/**
* 멀티라인 입력 컴포넌트.
* @param className 추가 CSS 클래스
* @param props 네이티브 textarea의 모든 속성(placeholder, rows, onChange )
*/
const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(({ className, ...props }, ref) => {
return <textarea ref={ref} className={clsx(styles.textarea, className)} {...props} />;
});
TextArea.displayName = 'TextArea';
export default TextArea;

7 changes: 7 additions & 0 deletions src/components/input/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export { default as Input } from './Input';
export { default as PasswordInput } from './PasswordInput';
export { default as AccountInput } from './AccountInput';
export { default as ChangePassword } from './ChangePassword';
export { default as TextArea } from './TextArea';
export { default as ActionTextArea } from './ActionTextArea';
export { default as CommentInput } from './CommentInput';
29 changes: 29 additions & 0 deletions src/components/input/styles/AccountInput.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
.container {
display: flex;
flex-direction: column;
gap: 24px;
width: 100%;
}

.field {
display: flex;
flex-direction: column;
gap: 8px;
}

.label {
font-family: var(--font-pretendard), 'Pretendard', sans-serif;
font-weight: 500;
font-size: 14px;
line-height: 17px;
color: #0f172a;
}

.readOnly {
background: #f1f5f9;
color: #94a3b8;
}

.buttonArea {
display: flex;
}
26 changes: 26 additions & 0 deletions src/components/input/styles/ActionTextArea.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
.wrapper {
position: relative;
width: 100%;
}

.textarea {
min-height: auto;
padding-right: 56px;
overflow-y: hidden;
}

.action {
position: absolute;
right: 16px;
bottom: 12px;
display: flex;
align-items: center;
background: none;
border: none;
padding: 0;
cursor: pointer;
}

.action:disabled {
cursor: default;
}
24 changes: 24 additions & 0 deletions src/components/input/styles/ChangePassword.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
.container {
display: flex;
flex-direction: column;
gap: 24px;
width: 100%;
}

.field {
display: flex;
flex-direction: column;
gap: 8px;
}

.label {
font-family: var(--font-pretendard), 'Pretendard', sans-serif;
font-weight: 500;
font-size: 14px;
line-height: 17px;
color: #0f172a;
}

.buttonArea {
display: flex;
}
9 changes: 9 additions & 0 deletions src/components/input/styles/CommentInput.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.wrapper {
border-top: 1px solid #e2e8f0;
border-bottom: 1px solid #e2e8f0;
}

.textarea {
border: none;
border-radius: 0;
}
18 changes: 18 additions & 0 deletions src/components/input/styles/Input.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
.input {
width: 100%;
height: 48px;
border-radius: 12px;
border: 1px solid #e2e8f0;
padding: 16px;
background: #ffffff;
font-family: var(--font-pretendard), 'Pretendard', sans-serif;
font-weight: 400;
font-size: 16px;
line-height: 19px;
color: #0f172a;
outline: none;
}

.input::placeholder {
color: #64748b;
}
21 changes: 21 additions & 0 deletions src/components/input/styles/PasswordInput.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
.wrapper {
position: relative;
width: 100%;
}

.input {
padding-right: 48px;
}

.toggleButton {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
}
20 changes: 20 additions & 0 deletions src/components/input/styles/TextArea.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
.textarea {
width: 100%;
min-height: 75px;
border-radius: 12px;
border: 1px solid #e2e8f0;
padding: 12px 16px;
background: #ffffff;
font-family: var(--font-pretendard), 'Pretendard', sans-serif;
font-weight: 400;
font-size: 16px;
line-height: 19px;
color: #0f172a;
outline: none;
resize: none;
overflow-y: auto;
}

.textarea::placeholder {
color: #64748b;
}
Loading