-
Notifications
You must be signed in to change notification settings - Fork 3
인풋 공통 컴포넌트 구현 #15
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
인풋 공통 컴포넌트 구현 #15
Changes from all commits
0a9f2ce
73168b7
e9d4bce
e3d2636
225f9fa
928df83
7ecb576
7a596f6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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> | ||
| {children && <div className={styles.buttonArea}>{children}</div>} | ||
| </div> | ||
| ); | ||
| } | ||
| 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> | ||
| ); | ||
| } |
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이 컴포넌트의 사용성과 접근성을 개선하기 위해 두 가지를 제안합니다:
이 변경을 적용하려면
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 스크린 리더가 무엇인지 몰라 지피티 한테 물어보니 시각장애인 (전맹 / 저시력), 키보드만 사용하는 유저 등 일반적인 상황보다 좀 특수한 상황에서 웹을 사용할때 쓰는 장치같습니다. 그 부분의 디테일도 챙겨야 할지 의문이긴 합니다. 스크린리더 접근성도 챙긴다면 seo 도 자연스레 따라 온다라고 하기는 하는데 논의 해보아야 할거같습니다. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className={styles.buttonArea}>{children}</div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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} | ||
| /> | ||
| ); | ||
| } |
| 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 }, | ||
| }, | ||
| }; |
| 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> | ||
| )} | ||
| </> | ||
| ); | ||
| } |
| 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 }, | ||
| }, | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
label과Input을htmlFor와id속성으로 연결하면 웹 접근성을 향상시킬 수 있습니다. 스크린 리더 사용자가 각 입력 필드가 어떤 라벨에 해당하는지 명확히 알 수 있게 됩니다.React.useId훅을 사용하면 고유한 ID를 쉽게 생성할 수 있습니다.