Skip to content
39 changes: 39 additions & 0 deletions src/components/input/AccountInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useId } from 'react';

import Input from './Input';
import { AccountInputProps } from './types/types';
import styles from './styles/AccountInput.module.css';

/**
* 프로필 페이지용 계정 정보 표시 컴포넌트.
* 이메일과 비밀번호를 읽기 전용으로 보여줍니다.
* children 슬롯에 변경하기 버튼 등을 주입할 수 있습니다.
*/
export default function AccountInput({ email, children }: AccountInputProps) {
const emailId = useId();
const passwordId = useId();

return (
<div className={styles.container}>
<div className={styles.field}>
<label htmlFor={emailId} className={styles.label}>
이메일
</label>
<Input id={emailId} type="email" value={email} disabled className={styles.readOnly} />
</div>
<div className={styles.field}>
<label htmlFor={passwordId} className={styles.label}>
비밀번호
</label>
<Input
id={passwordId}
type="password"
value="••••••••"
disabled
className={styles.readOnly}
/>
</div>
Comment on lines +18 to +35

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

labelInputhtmlForid 속성으로 연결하면 웹 접근성을 향상시킬 수 있습니다. 스크린 리더 사용자가 각 입력 필드가 어떤 라벨에 해당하는지 명확히 알 수 있게 됩니다. React.useId 훅을 사용하면 고유한 ID를 쉽게 생성할 수 있습니다.

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>
);
}
59 changes: 59 additions & 0 deletions src/components/input/ActionTextArea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
'use client';

import { 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';

/**
* 전송 버튼이 포함된 텍스트 입력 컴포넌트.
* 텍스트를 입력하면 전송 버튼이 활성화되고, 높이가 내용에 맞게 자동 조절됩니다.
* CommentInput의 기반 컴포넌트로, 단독으로도 사용할 수 있습니다.
*/
export default function ActionTextArea({
onSubmit,
wrapperClassName,
className,
onChange,
...props
}: ActionTextAreaProps) {
const [hasValue, setHasValue] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);

return (
<div
className={clsx(styles.wrapper, wrapperClassName)}
role="group"
aria-label="텍스트 입력 및 전송"
>
<TextArea
ref={textareaRef}
rows={1}
className={clsx(styles.textarea, className)}
onChange={(e) => {
setHasValue(e.target.value.length > 0);
const el = textareaRef.current;
if (el) {
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
}
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>
);
}
50 changes: 50 additions & 0 deletions src/components/input/ChangePassword.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useId } from 'react';

import Input from './Input';
import { ChangePasswordProps } from './types/types';
import styles from './styles/ChangePassword.module.css';

/**
* 비밀번호 변경 폼 컴포넌트.
* 새 비밀번호 + 확인 입력 필드로 구성되며, isEditing이 false면 입력이 비활성화됩니다.
* children 슬롯에 변경하기/취소 버튼 등을 주입할 수 있습니다.
*/
export default function ChangePassword({
isEditing = false,
newPasswordProps,
confirmPasswordProps,
children,
}: ChangePasswordProps) {
const newPasswordId = useId();
const confirmPasswordId = useId();

return (
<div className={styles.container}>
<div className={styles.field}>
<label htmlFor={newPasswordId} className={styles.label}>
새 비밀번호
</label>
<Input
id={newPasswordId}
type="password"
placeholder="새 비밀번호를 입력해 주세요."
disabled={!isEditing}
{...newPasswordProps}
/>
</div>
<div className={styles.field}>
<label htmlFor={confirmPasswordId} className={styles.label}>
새 비밀번호 확인
</label>
<Input
id={confirmPasswordId}
type="password"
placeholder="새 비밀번호를 다시 한 번 입력해 주세요."
disabled={!isEditing}
{...confirmPasswordProps}
/>
</div>
Comment on lines +23 to +46

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

이 컴포넌트의 사용성과 접근성을 개선하기 위해 두 가지를 제안합니다:

  1. 접근성 향상: label과 입력 필드를 htmlForid 속성으로 연결하여 스크린 리더 사용자들이 더 쉽게 양식을 탐색할 수 있도록 해야 합니다. React.useId() 훅을 사용하면 고유 ID를 생성할 수 있습니다.
  2. 사용성 향상: Input 대신 PasswordInput 컴포넌트를 사용하면 사용자가 입력한 비밀번호를 확인할 수 있는 토글 기능을 제공하여 사용자 경험을 개선할 수 있습니다.

이 변경을 적용하려면 Input 대신 PasswordInput을 import해야 합니다.

Suggested change
<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.field}>
<label htmlFor="new-password" className={styles.label}>새 비밀번호</label>
<PasswordInput
id="new-password"
placeholder="새 비밀번호를 입력해 주세요."
disabled={!isEditing}
{...newPasswordProps}
/>
</div>
<div className={styles.field}>
<label htmlFor="confirm-password" className={styles.label}>새 비밀번호 확인</label>
<PasswordInput
id="confirm-password"
placeholder="새 비밀번호를 다시 한 번 입력해 주세요."
disabled={!isEditing}
{...confirmPasswordProps}
/>
</div>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

스크린 리더가 무엇인지 몰라 지피티 한테 물어보니 시각장애인 (전맹 / 저시력), 키보드만 사용하는 유저 등 일반적인 상황보다 좀 특수한 상황에서 웹을 사용할때 쓰는 장치같습니다. 그 부분의 디테일도 챙겨야 할지 의문이긴 합니다. 스크린리더 접근성도 챙긴다면 seo 도 자연스레 따라 온다라고 하기는 하는데 논의 해보아야 할거같습니다.

<div className={styles.buttonArea}>{children}</div>
</div>
);
}
19 changes: 19 additions & 0 deletions src/components/input/CommentInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import clsx from 'clsx';
import ActionTextArea from './ActionTextArea';
import { CommentInputProps } from './types/types';
import styles from './styles/CommentInput.module.css';

/**
* 댓글 입력 컴포넌트.
* ActionTextArea를 위아래 보더 스타일로 감싸서 댓글 영역에 맞는 디자인을 제공합니다.
* 전송 버튼과 높이 자동 조절은 ActionTextArea에서 상속됩니다.
*/
export default function CommentInput({ className, ...props }: CommentInputProps) {
return (
<ActionTextArea
wrapperClassName={styles.wrapper}
className={clsx(styles.textarea, className)}
{...props}
/>
);
}
89 changes: 89 additions & 0 deletions src/components/input/Input.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';

import { fn } from 'storybook/test';

import Input from './Input';

const meta = {
title: 'Components/Input',
component: Input,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
args: {
placeholder: '텍스트를 입력해 주세요.',
onChange: fn(),
},
argTypes: {
type: {
control: 'inline-radio',
options: ['text', 'email', 'password'],
},
errorMessage: {
control: 'text',
},
isError: {
control: 'boolean',
},
disabled: {
control: 'boolean',
},
},
decorators: [
(Story) => (
<div style={{ width: 460 }}>
<Story />
</div>
),
],
} satisfies Meta<typeof Input>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {};

export const Email: Story = {
args: {
type: 'email',
placeholder: '이메일을 입력해 주세요.',
},
};

export const EmailWithError: Story = {
args: {
type: 'email',
placeholder: '이메일을 입력해 주세요.',
errorMessage: '유효한 이메일이 아닙니다',
},
};

export const ErrorBorderOnly: Story = {
args: {
isError: true,
placeholder: '보더만 빨간색',
},
};

export const Disabled: Story = {
args: {
disabled: true,
value: '비활성 상태',
},
};

export const Overview: Story = {
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, width: 460 }}>
<Input placeholder="기본 Input" />
<Input type="email" placeholder="이메일" />
<Input type="email" placeholder="이메일" errorMessage="유효한 이메일이 아닙니다" />
<Input placeholder="보더만 에러" isError />
<Input placeholder="비활성" disabled />
</div>
),
parameters: {
controls: { disable: true },
},
};
32 changes: 32 additions & 0 deletions src/components/input/Input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use client';

import { useId } from 'react';
import clsx from 'clsx';
import { InputProps } from './types/types';
import styles from './styles/Input.module.css';

/**
* 범용 텍스트 입력 컴포넌트.
* 네이티브 `<input>`의 모든 속성을 지원하며, errorMessage를 전달하면
* 빨간 테두리 + 하단 에러 텍스트가 자동으로 표시됩니다.
*/
export default function Input({ className, errorMessage, isError, ...props }: InputProps) {
const hasError = isError || !!errorMessage;
const errorId = useId();

return (
<>
<input
aria-invalid={hasError || undefined}
aria-describedby={errorMessage ? errorId : undefined}
{...props}
className={clsx(styles.input, hasError && styles.error, className)}
/>
{errorMessage && (
<p id={errorId} role="alert" className={styles.errorMessage}>
{errorMessage}
</p>
)}
</>
);
}
63 changes: 63 additions & 0 deletions src/components/input/PasswordInput.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';

import { fn } from 'storybook/test';

import PasswordInput from './PasswordInput';

const meta = {
title: 'Components/PasswordInput',
component: PasswordInput,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
args: {
placeholder: '비밀번호를 입력해 주세요.',
onChange: fn(),
},
argTypes: {
errorMessage: {
control: 'text',
},
disabled: {
control: 'boolean',
},
},
decorators: [
(Story) => (
<div style={{ width: 460 }}>
<Story />
</div>
),
],
} satisfies Meta<typeof PasswordInput>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {};

export const WithError: Story = {
args: {
errorMessage: '비밀번호를 입력해주세요.',
},
};

export const Disabled: Story = {
args: {
disabled: true,
},
};

export const Overview: Story = {
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, width: 460 }}>
<PasswordInput placeholder="기본 비밀번호" />
<PasswordInput placeholder="에러 상태" errorMessage="비밀번호를 입력해주세요." />
<PasswordInput placeholder="비활성" disabled />
</div>
),
parameters: {
controls: { disable: true },
},
};
Loading