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>
106 lines
2.7 KiB
Svelte
106 lines
2.7 KiB
Svelte
<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>
|