feat: 입력 프리미티브 (TextInput / Textarea / Select) + tsconfig 보정

UX/UI 개편 Phase A-6.

신규 컴포넌트 (lib/components/ui/)
- TextInput.svelte: \$bindable value, label/error/hint, leading/trailing icon,
  \$props.id() 기반 SSR-safe 자동 id, aria-describedby 자동 연결.
- Textarea.svelte: TextInput과 동일 구조 + autoGrow 옵션
  (\$effect로 scrollHeight 동기화, maxRows 지원).
- Select.svelte: 네이티브 <select> 래퍼, ChevronDown 표시.
  options: { value, label, disabled? }[]

빌드 환경 보정
- frontend/tsconfig.json 신규: svelte-kit 자동 생성 .svelte-kit/tsconfig.json을
  extends. 이게 없으면 svelte-check가 \$lib path mapping과 .svelte.ts 모듈
  resolution을 못 잡아 "Cannot find module" 에러 발생. SvelteKit 표준 패턴.
  strict는 false로 시작 (기존 코드 implicit any 다수 — 점진적 정리 예정).
- Button/IconButton/EmptyState/TextInput의 icon prop 타입을 IconComponent(any)로
  완화. lucide-svelte v0.400은 legacy SvelteComponentTyped 기반이라 Svelte 5의
  Component<P, E, B> 시그니처와 호환 안 됨. v0.469+ 업그레이드 후 좁힐 예정.

svelte-check: 0 errors / 8 warnings (전부 기존 latent, 새 코드 무관)
build: 2.07s 무경고

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-07 08:39:44 +09:00
parent 70b27d4a51
commit ad23925ed5
7 changed files with 350 additions and 6 deletions

View File

@@ -0,0 +1,105 @@
<script lang="ts">
// 공용 textarea. TextInput과 같은 구조 + autoGrow 옵션.
interface Props {
value?: string;
label?: string;
error?: string;
hint?: string;
placeholder?: string;
id?: string;
name?: string;
rows?: number;
disabled?: boolean;
readonly?: boolean;
required?: boolean;
autoGrow?: boolean;
maxRows?: number;
class?: string;
}
let {
value = $bindable(''),
label,
error,
hint,
placeholder,
id: idProp,
name,
rows = 3,
disabled = false,
readonly = false,
required = false,
autoGrow = false,
maxRows,
class: className = '',
...rest
}: Props = $props();
const autoId = $props.id();
let inputId = $derived(idProp ?? `textarea-${autoId}`);
let hintId = $derived(`${inputId}-hint`);
let errorId = $derived(`${inputId}-error`);
let describedBy = $derived(
[error ? errorId : null, hint && !error ? hintId : null].filter(Boolean).join(' ') || undefined
);
let textareaEl: HTMLTextAreaElement | undefined = $state();
// autoGrow: value 변경 시마다 height를 scrollHeight로 동기화
$effect(() => {
if (!autoGrow || !textareaEl) return;
// value를 reactivity 의존성으로 등록
void value;
textareaEl.style.height = 'auto';
let next = textareaEl.scrollHeight;
if (maxRows) {
const lineHeight = parseFloat(getComputedStyle(textareaEl).lineHeight) || 20;
const maxHeight = lineHeight * maxRows;
if (next > maxHeight) next = maxHeight;
}
textareaEl.style.height = next + 'px';
});
let textareaClass = $derived(
[
'w-full px-3 py-2 rounded-md text-sm bg-bg text-text placeholder:text-faint',
'border outline-none transition-colors',
error
? 'border-error focus:border-error focus:ring-2 focus:ring-error/30'
: 'border-default focus:border-accent focus:ring-2 focus:ring-accent-ring',
'disabled:opacity-50 disabled:cursor-not-allowed',
autoGrow ? 'resize-none overflow-hidden' : 'resize-y',
].join(' ')
);
</script>
<div class={'flex flex-col gap-1 ' + className}>
{#if label}
<label for={inputId} class="text-xs font-medium text-dim">
{label}
{#if required}<span class="text-error">*</span>{/if}
</label>
{/if}
<textarea
id={inputId}
bind:this={textareaEl}
bind:value
{name}
{rows}
{placeholder}
{disabled}
{readonly}
{required}
class={textareaClass}
aria-invalid={error ? 'true' : undefined}
aria-describedby={describedBy}
{...rest}
></textarea>
{#if error}
<p id={errorId} class="text-xs text-error">{error}</p>
{:else if hint}
<p id={hintId} class="text-xs text-faint">{hint}</p>
{/if}
</div>