Files
frontend-svelte/src/shared/ui/Input/Input.svelte

158 lines
5.8 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
Component: Input
design-system input. Zero border-radius, Space Grotesk, precise states.
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import XIcon from '@lucide/svelte/icons/x';
import type { Snippet } from 'svelte';
import { cubicOut } from 'svelte/easing';
import type { HTMLInputAttributes } from 'svelte/elements';
import { scale } from 'svelte/transition';
import type {
InputSize,
InputVariant,
} from './types';
interface Props extends Omit<HTMLInputAttributes, 'size'> {
variant?: InputVariant;
size?: InputSize;
/** Marks the input as invalid — red border + ring, red helper text. */
error?: boolean;
/** Helper / error message rendered below the input. */
helperText?: string;
/** Show an animated × button when the input has a value. */
showClearButton?: boolean;
/** Called when the clear button is clicked. */
onclear?: () => void;
/**
* Snippet for the left icon slot.
* Receives `size` as an argument for convenient icon sizing.
* @example {#snippet leftIcon(size)}<SearchIcon size={inputIconSize[size]} />{/snippet}
*/
leftIcon?: Snippet<[InputSize]>;
/**
* Snippet for the right icon slot (rendered after the clear button).
* Receives `size` as an argument.
*/
rightIcon?: Snippet<[InputSize]>;
fullWidth?: boolean;
value?: string | number | readonly string[];
class?: string;
}
let {
variant = 'default',
size = 'md',
error = false,
helperText,
showClearButton = false,
onclear,
leftIcon,
rightIcon,
fullWidth = false,
value = $bindable(''),
class: className,
...rest
}: Props = $props();
// ── Size config ──────────────────────────────────────────────────────────────
const sizeConfig: Record<InputSize, { input: string; text: string; height: string; clearIcon: number }> = {
sm: { input: 'px-3 py-1.5', text: 'text-sm', height: 'h-8', clearIcon: 12 },
md: { input: 'px-4 py-2', text: 'text-base', height: 'h-10', clearIcon: 14 },
lg: { input: 'px-4 py-3', text: 'text-lg', height: 'h-12', clearIcon: 16 },
xl: { input: 'px-4 py-3', text: 'text-xl', height: 'h-14', clearIcon: 18 },
};
// ── Variant config ───────────────────────────────────────────────────────────
const variantConfig: Record<InputVariant, { base: string; focus: string; error: string }> = {
default: {
base: 'bg-paper dark:bg-paper border border-black/5 dark:border-white/10',
focus: 'focus:border-brand focus:ring-1 focus:ring-brand/20',
error: 'border-brand ring-1 ring-brand/20',
},
underline: {
base: 'bg-transparent border-0 border-b border-neutral-300 dark:border-neutral-700',
focus: 'focus:border-brand',
error: 'border-brand',
},
filled: {
base: 'bg-surface dark:bg-paper border border-transparent',
focus: 'focus:border-brand focus:ring-1 focus:ring-brand/20',
error: 'border-brand ring-1 ring-brand/20',
},
};
const hasValue = $derived(value !== undefined && value !== '');
const showClear = $derived(showClearButton && hasValue && !!onclear);
const hasRightSlot = $derived(!!rightIcon || showClearButton);
const cfg = $derived(sizeConfig[size]);
const styles = $derived(variantConfig[variant]);
const inputClasses = $derived(cn(
"font-['Space_Grotesk'] rounded-none outline-none transition-all duration-200",
'text-neutral-900 dark:text-neutral-100',
'placeholder:text-neutral-400 dark:placeholder:text-neutral-600',
'disabled:opacity-50 disabled:cursor-not-allowed',
cfg.input,
cfg.text,
cfg.height,
styles.base,
error ? styles.error : styles.focus,
!!leftIcon && 'pl-10',
hasRightSlot && 'pr-10',
fullWidth && 'w-full',
className,
));
</script>
<div class={cn('flex flex-col gap-1', fullWidth && 'w-full')}>
<div class={cn('relative group', fullWidth && 'w-full')}>
<!-- Left icon slot -->
{#if leftIcon}
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400 dark:text-neutral-600 pointer-events-none z-10 flex items-center">
{@render leftIcon(size)}
</div>
{/if}
<!-- Input -->
<input class={inputClasses} bind:value {...rest} />
<!-- Right slot: clear button + rightIcon -->
{#if hasRightSlot}
<div class="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2 z-10">
{#if showClear}
<button
type="button"
tabindex={-1}
onclick={onclear}
class="text-neutral-400 hover:text-brand transition-colors p-0.5 flex items-center"
in:scale={{ duration: 150, start: 0.8, easing: cubicOut }}
out:scale={{ duration: 100, start: 0.8, easing: cubicOut }}
>
<XIcon size={cfg.clearIcon} />
</button>
{/if}
{#if rightIcon}
<div class="text-neutral-400 dark:text-neutral-600 flex items-center">
{@render rightIcon(size)}
</div>
{/if}
</div>
{/if}
</div>
<!-- Helper / error text -->
{#if helperText}
<span
class={cn(
"text-[0.625rem] font-['Space_Mono'] tracking-wide px-1",
error ? 'text-brand ' : 'text-neutral-500 dark:text-neutral-400',
)}
>
{helperText}
</span>
{/if}
</div>