Skip to content
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
4 changes: 4 additions & 0 deletions public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,10 @@
"토론 주제를 입력해주세요": "Please enter a debate topic",
"팀명": "Team name",
"팀명은 최대 15자까지 입력할 수 있습니다.": "Team name can be up to 15 characters.",
"팀명에 사용할 수 없는 문자가 포함되어 있습니다.": "Team name contains characters that are not allowed.",
"시간표 이름은 최대 {{val0}}자까지 입력할 수 있습니다.": "Table name can be up to {{val0}} characters.",
"시간표 이름에 사용할 수 없는 문자가 포함되어 있습니다.": "Table name contains characters that are not allowed.",
"토론 주제는 최대 {{val0}}자까지 입력할 수 있습니다.": "Debate topic can be up to {{val0}} characters.",
"다음": "Next",
"볼륨 조절": "Volume control",
"투표 종료에 실패했습니다.": "Failed to end the vote.",
Expand Down
4 changes: 4 additions & 0 deletions public/locales/ko/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,10 @@
"토론 주제를 입력해주세요": "토론 주제를 입력해주세요",
"팀명": "팀명",
"팀명은 최대 15자까지 입력할 수 있습니다.": "팀명은 최대 15자까지 입력할 수 있습니다.",
"팀명에 사용할 수 없는 문자가 포함되어 있습니다.": "팀명에 사용할 수 없는 문자가 포함되어 있습니다.",
"시간표 이름은 최대 {{val0}}자까지 입력할 수 있습니다.": "시간표 이름은 최대 {{val0}}자까지 입력할 수 있습니다.",
"시간표 이름에 사용할 수 없는 문자가 포함되어 있습니다.": "시간표 이름에 사용할 수 없는 문자가 포함되어 있습니다.",
"토론 주제는 최대 {{val0}}자까지 입력할 수 있습니다.": "토론 주제는 최대 {{val0}}자까지 입력할 수 있습니다.",
"다음": "다음",
"볼륨 조절": "볼륨 조절",
"투표 종료에 실패했습니다.": "투표 종료에 실패했습니다.",
Expand Down
55 changes: 54 additions & 1 deletion src/components/ClearableInput/ClearableInput.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,52 @@
import clsx from 'clsx';
import { InputHTMLAttributes } from 'react';
import { IoMdCloseCircle } from 'react-icons/io';
import { useTranslation } from 'react-i18next';

interface ClearableInputProps extends InputHTMLAttributes<HTMLInputElement> {
value: string;
disabled?: boolean;
onClear?: () => void;
/** 검증 실패 시 붉은 테두리로 표시 */
isError?: boolean;
/** 설정 시 입력창 경계 바로 아래에 `현재/최대` 글자 수 카운터를 노출한다. */
maxCount?: number;

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.

초과 글자 이상으로는 입력되지 않도록 하는 방법이랑 비교했을 때 이 방식을 선택하신 이유도 궁금해요!

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.

안그래도 처음 작업 같은 경우는 Hard Limit이 있었는데요.
Hard Limit은 유저에게 제한 요건을 필수적으로 만족하도록 해서 개발자 입장에서는 안정성을 보장받을 수 있지만
잘못된 입력사안에 대한 회복을 인지하고 몇자를 줄여야 하는지 안내하는 가이드가 부족하다고 느꼈던 것 같아요.

Hard Limit을 쓰면서 느꼈던 불편함

  • 내가 지금 쓴 글자를 몇자 줄여야 하는지 갸늠이 안됨
  • 글자 제한이 정확히 몇자인지 인지되지 못하는 문제
  • 시각적으로 현재 잘못된 입력을 유저가 인지하지 못하는 문제

그래서 Hard Limit에서 일단 유저의 입력을 받고(붙여넣기 등등) 이를 제한에 맞게 조정할 수 있도록 글자수 카운터로 바꾸었습니다.

썬데이는 Hard Limit 넣는게 더 괜찮다고 생각하나요? 만약 그 경우에는 붉은색 테두리 + 디바운싱 관련된 부분이 필요치 않게 될 수 있을 것 같아요.

@useon useon Aug 22, 2026

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.

알림창 적용이 된 것과 콜리의 의견을 확인하고 생각해봤어요. 지정 글자수 이상으로 입력이 안되게 하기 + 글자수 카운터 두고 잘못된 입력 사안에 대한 가이드를 인라인으로 바로 보여주는 방법은 어떨까요? ? ? ?

요렇게 바로 밑에 보이는 방식
image

이유는 아래와 같아요.

  1. 현재는 확인을 누른 후 알림창으로 잘못된 입력 사안에 대한 피드백이 나와서 카운트는 즉각적으로 보일 수 있도록 만들었는데 메세지는 알림창으로 나오니까 (메세지 인지까지 다음 버튼 + 안내창 확인 버튼 두 번의 유저 행동이 필요함) 즉각적인 느낌은 덜 드는 것 같아요.

  2. 아래 사진 처럼 여러개가 한번에 나와서 잘못된 입력을 많이 한 경우에 모든 메세지를 바로 받아들이기 힘들수도 있다고 생각합니다 !!

image

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.

좋은 아이디어 감사합니다 썬데이! 저도 썬데이가 말한 방향이 합리적이라고 생각해서 인라인 메시지를 박스 내에 현출하는 것으로 바꾸었습니다.

다만 Hard Limit을 걸지는 않았어요. 그 이유는 Hard Limit과 카운터가 공존할 수 없기 때문입니다. 이미 우리가 원한 limit 까지 밖에 입력을 못하도록 강제하는 순간 카운터가 큰 의미가 없어지는 상황이 생겼어요.

그래서 인라인 박스 현출 + 만약 조건을 만족시키지 못하는 박스가 있다면 다음으로 이동하는 버튼이 blocked 되도록 수정했습니다!

image

/** 설정 시 입력창 경계 바로 아래(왼쪽)에 붉은 에러 메시지를 노출한다. */
errorMessage?: string;
}

export default function ClearableInput({
value,
onClear,
disabled = false,
isError = false,
maxCount,
errorMessage,
className,
id,
...rest
}: ClearableInputProps) {
const { t } = useTranslation();
const hasCounter = typeof maxCount === 'number';
const isOverLimit = hasCounter && value.length > maxCount;
const errorId = errorMessage && id ? `${id}-error` : undefined;

return (
<div className={clsx('relative w-full', className)}>
<input
{...rest}
id={id}
value={value}
disabled={disabled}
className="text-body h-[48px] w-full appearance-none rounded-[4px] border border-default-border p-[12px] text-default-black placeholder-default-border focus:outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
aria-invalid={isError || undefined}
aria-describedby={errorId}
className={clsx(
'text-body h-[48px] w-full appearance-none rounded-[4px] p-[12px] text-default-black placeholder-default-border focus:outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
// box-border 기준이라 테두리를 굵혀도 외곽 크기(48px)는 유지 → 레이아웃 밀림 없음
isError
? 'border-2 border-semantic-error'
: 'border border-default-border',
)}
/>
{value && !disabled && onClear && (
<button
Expand All @@ -32,6 +57,34 @@ export default function ClearableInput({
<IoMdCloseCircle />
</button>
)}
{hasCounter && (
// 박스 경계 바로 아래에 절대배치 → 레이아웃을 밀지 않아 행 간격이 균일하게 유지된다.
<span
aria-live="polite"
className={clsx(
'pointer-events-none absolute right-1 top-full mt-[2px] text-[11px] leading-none',
isOverLimit
? 'font-semibold text-semantic-error'
: 'text-default-neutral',
)}
>
{t('{{current}}/{{max}}', {
current: value.length,
max: maxCount,
})}
</span>
)}
{errorMessage && (
// 카운터(우하단)와 겹치지 않도록 우측 여백을 두고 좌하단에 절대배치한다.
<span
id={errorId}
role="alert"
aria-live="polite"
className="absolute left-1 top-full mt-[2px] pr-14 text-[12px] font-semibold leading-none text-semantic-error"
>
{errorMessage}
</span>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)}
</div>
);
}
18 changes: 18 additions & 0 deletions src/hooks/useDebounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useEffect, useState } from 'react';

/**
* 값의 변경이 delay(ms) 동안 멈추면(= 입력이 끝났다고 판단되면) 갱신되는 디바운스 값을 반환한다.
* 입력 중에는 이전 값을 유지하므로, 타이핑 도중이 아니라 입력이 마쳤을 때 검증을 트리거하는 데 사용한다.
*/
const useDebounce = <T>(value: T, delay = 400): T => {
const [debouncedValue, setDebouncedValue] = useState<T>(value);

useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);

return debouncedValue;
};

export default useDebounce;
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { GlobalPortal } from '../../../../util/GlobalPortal';
import { DebateInfo } from '../../../../type/type';
import TableNameAndType from './TableNameAndType';

// ------------------
// 헬퍼: 유효한 기본 info 를 만들고 필요한 필드만 덮어쓴다.
// ------------------
function makeInfo(overrides: Partial<DebateInfo> = {}): DebateInfo {
return {
name: '토론 시간표',
agenda: '토론 주제',
prosTeamName: '우리팀',
consTeamName: '상대팀',
...overrides,
};
}

function renderComponent(info: DebateInfo, onButtonClick = vi.fn()) {
const queryClient = new QueryClient();
render(
<QueryClientProvider client={queryClient}>
<GlobalPortal.Provider>
<MemoryRouter>
<TableNameAndType
info={info}
isLoading={false}
onInfoChange={vi.fn()}
onButtonClick={onButtonClick}
/>
</MemoryRouter>
</GlobalPortal.Provider>
</QueryClientProvider>,
);
}

async function clickNext() {
await userEvent.click(screen.getByRole('button', { name: '다음' }));
}

describe('TableNameAndType - 제출 검증 인라인 에러', () => {
// useDebounce 는 초기 useState(value) 라 마운트 시 디바운스값=초기값 →
// 무효값으로 렌더하면 인라인 사유 메시지가 즉시 노출된다.
it('시간표 이름 길이 초과 시 사유 메시지를 인라인으로 노출하고 진행을 막는다', async () => {
const onButtonClick = vi.fn();
renderComponent(makeInfo({ name: 'a'.repeat(21) }), onButtonClick);

expect(
await screen.findByText(
'시간표 이름은 최대 20자까지 입력할 수 있습니다.',
),
).toBeInTheDocument();

await clickNext();

expect(onButtonClick).not.toHaveBeenCalled();
});

it('토론 주제 길이 초과 시 사유 메시지를 인라인으로 노출한다', async () => {
renderComponent(makeInfo({ agenda: 'a'.repeat(256) }));

expect(
await screen.findByText('토론 주제는 최대 255자까지 입력할 수 있습니다.'),
).toBeInTheDocument();
});

it('팀명 길이 초과 시 사유 메시지를 인라인으로 노출한다', async () => {
renderComponent(makeInfo({ prosTeamName: 'a'.repeat(16) }));

expect(
await screen.findByText('팀명은 최대 15자까지 입력할 수 있습니다.'),
).toBeInTheDocument();
});

it('팀명에 사용할 수 없는 문자가 있으면 형식 오류 메시지를 인라인으로 노출한다', async () => {
// 제어문자(U+0001)는 NAME_REGEX 를 통과하지 못한다(FORM 오류).
renderComponent(
makeInfo({ consTeamName: `team${String.fromCharCode(1)}` }),
);

expect(
await screen.findByText(
'팀명에 사용할 수 없는 문자가 포함되어 있습니다.',
),
).toBeInTheDocument();
});

it('여러 필드가 잘못되면 각 입력창 아래 사유 메시지를 동시에 노출한다', async () => {
const onButtonClick = vi.fn();
renderComponent(
makeInfo({ name: 'a'.repeat(21), prosTeamName: 'b'.repeat(16) }),
onButtonClick,
);

expect(
await screen.findByText(
'시간표 이름은 최대 20자까지 입력할 수 있습니다.',
),
).toBeInTheDocument();
expect(
screen.getByText('팀명은 최대 15자까지 입력할 수 있습니다.'),
).toBeInTheDocument();

await clickNext();

expect(onButtonClick).not.toHaveBeenCalled();
});

it('모든 필드가 유효하면 에러 메시지 없이 onButtonClick 을 호출한다', async () => {
const onButtonClick = vi.fn();
renderComponent(makeInfo(), onButtonClick);

await clickNext();

expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(onButtonClick).toHaveBeenCalledOnce();
});
});
Loading
Loading