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:
105
frontend/src/lib/components/ui/Textarea.svelte
Normal file
105
frontend/src/lib/components/ui/Textarea.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user