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

@@ -7,7 +7,12 @@
// - loading이면 disabled + 회전 아이콘
import { Loader2 } from 'lucide-svelte';
import type { Snippet } from 'svelte';
import type { Component } from 'svelte';
// lucide-svelte v0.400은 아직 legacy SvelteComponentTyped 기반이라 Svelte 5의
// Component 타입과 호환되지 않는다. 향후 lucide v0.469+ 업그레이드 시 정식 타입으로 좁히기.
// 우리는 size prop만 넘기므로 any로 받아도 충분히 안전.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type IconComponent = any;
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger';
type Size = 'sm' | 'md';
@@ -18,7 +23,7 @@
loading?: boolean;
disabled?: boolean;
type?: 'button' | 'submit' | 'reset';
icon?: Component;
icon?: IconComponent;
iconPosition?: 'left' | 'right';
href?: string;
target?: string;

View File

@@ -2,10 +2,13 @@
// 빈 상태/추후 지원/검색 결과 없음 등에 사용.
// children은 액션 슬롯 (Button 등).
import type { Snippet } from 'svelte';
import type { Component } from 'svelte';
// lucide-svelte v0.400 legacy 타입 호환을 위한 임시 IconComponent.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type IconComponent = any;
interface Props {
icon?: Component;
icon?: IconComponent;
title: string;
description?: string;
children?: Snippet;

View File

@@ -2,13 +2,16 @@
// 정사각형 아이콘 전용 버튼. nav/toolbar에서 사용.
// aria-label 필수 (스크린 리더 라벨링).
import { Loader2 } from 'lucide-svelte';
import type { Component } from 'svelte';
// lucide-svelte v0.400은 legacy 타입. 향후 v0.469+ 업그레이드 후 정식 타입으로 좁히기.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type IconComponent = any;
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger';
type Size = 'sm' | 'md';
interface Props {
icon: Component;
icon: IconComponent;
'aria-label': string;
variant?: Variant;
size?: Size;

View File

@@ -0,0 +1,100 @@
<script lang="ts">
// 네이티브 <select> 래퍼. TextInput과 동일 시각 시스템.
// options 배열을 그룹 없이 단순 평면 리스트로 받는다.
import { ChevronDown } from 'lucide-svelte';
export interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}
interface Props {
value?: string;
options: SelectOption[];
label?: string;
error?: string;
hint?: string;
placeholder?: string;
id?: string;
name?: string;
disabled?: boolean;
required?: boolean;
class?: string;
}
let {
value = $bindable(''),
options,
label,
error,
hint,
placeholder,
id: idProp,
name,
disabled = false,
required = false,
class: className = '',
...rest
}: Props = $props();
const autoId = $props.id();
let inputId = $derived(idProp ?? `select-${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 selectClass = $derived(
[
'w-full h-9 pl-3 pr-9 rounded-md text-sm bg-bg text-text appearance-none',
'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',
].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}
<div class="relative">
<select
id={inputId}
bind:value
{name}
{disabled}
{required}
class={selectClass}
aria-invalid={error ? 'true' : undefined}
aria-describedby={describedBy}
{...rest}
>
{#if placeholder}
<option value="" disabled selected={value === ''}>{placeholder}</option>
{/if}
{#each options as opt (opt.value)}
<option value={opt.value} disabled={opt.disabled}>{opt.label}</option>
{/each}
</select>
<div
class="absolute inset-y-0 right-0 flex items-center pr-2.5 text-faint pointer-events-none"
>
<ChevronDown size={16} />
</div>
</div>
{#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>

View File

@@ -0,0 +1,115 @@
<script lang="ts">
// 공용 텍스트 입력. label/error/hint를 SSR-safe id로 ARIA 연결.
// - value는 $bindable
// - error 전달 시 빨간 보더 + 메시지 + aria-invalid
// - leading/trailing 아이콘은 lucide 컴포넌트 참조로 전달
import type { HTMLInputAttributes } from 'svelte/elements';
// lucide-svelte v0.400 legacy 타입 호환을 위한 임시 IconComponent.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type IconComponent = any;
interface Props {
value?: string;
label?: string;
error?: string;
hint?: string;
leadingIcon?: IconComponent;
trailingIcon?: IconComponent;
type?: 'text' | 'password' | 'email' | 'url' | 'tel' | 'number' | 'search';
placeholder?: string;
id?: string;
name?: string;
disabled?: boolean;
readonly?: boolean;
required?: boolean;
autocomplete?: HTMLInputAttributes['autocomplete'];
class?: string;
}
let {
value = $bindable(''),
label,
error,
hint,
leadingIcon: LeadingIcon,
trailingIcon: TrailingIcon,
type = 'text',
placeholder,
id: idProp,
name,
disabled = false,
readonly = false,
required = false,
autocomplete,
class: className = '',
...rest
}: Props = $props();
// SSR-safe 자동 id 생성 (Svelte 5.20+)
const autoId = $props.id();
let inputId = $derived(idProp ?? `input-${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 inputClass = $derived(
[
'w-full h-9 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',
LeadingIcon ? 'pl-9' : 'px-3',
TrailingIcon ? 'pr-9' : LeadingIcon ? 'pr-3' : '',
].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}
<div class="relative">
{#if LeadingIcon}
<div class="absolute inset-y-0 left-0 flex items-center pl-2.5 text-faint pointer-events-none">
<LeadingIcon size={16} />
</div>
{/if}
<input
id={inputId}
{type}
{name}
bind:value
{placeholder}
{disabled}
{readonly}
{required}
{autocomplete}
class={inputClass}
aria-invalid={error ? 'true' : undefined}
aria-describedby={describedBy}
{...rest}
/>
{#if TrailingIcon}
<div class="absolute inset-y-0 right-0 flex items-center pr-2.5 text-faint pointer-events-none">
<TrailingIcon size={16} />
</div>
{/if}
</div>
{#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>

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>