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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
100
frontend/src/lib/components/ui/Select.svelte
Normal file
100
frontend/src/lib/components/ui/Select.svelte
Normal 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>
|
||||
115
frontend/src/lib/components/ui/TextInput.svelte
Normal file
115
frontend/src/lib/components/ui/TextInput.svelte
Normal 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>
|
||||
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