feat(frontend): add EmailInput component with validation

EmailInput wraps shadcn Input with built-in validation (regex + browser checkValidity), normalization (trim + lowercase on blur), and inline error display. Follows PhoneInput.svelte pattern with  value/error and onvaluechange/onerrorchange/onblur callbacks.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-12 10:51:01 +01:00
co-authored by Sisyphus
parent 6902eabf47
commit 1cadbbe968
3 changed files with 121 additions and 0 deletions
@@ -0,0 +1,91 @@
<script lang="ts">
import { Input } from '$lib/components/ui/input/index.js';
import { isValidEmail, normalizeEmail } from '$lib/utils/email';
interface Props {
value?: string;
error?: string;
placeholder?: string;
required?: boolean;
disabled?: boolean;
class?: string;
id?: string;
onvaluechange?: (value: string) => void;
onerrorchange?: (error: string) => void;
onblur?: () => void;
}
let {
value = $bindable(''),
error = $bindable(''),
placeholder = 'email@example.com',
required = false,
disabled = false,
class: className = '',
id = 'email',
onvaluechange,
onerrorchange,
onblur
}: Props = $props();
let touched = $state(false);
function handleInput(e: Event) {
const target = e.target as HTMLInputElement;
value = target.value;
onvaluechange?.(value);
if (touched) {
validate(value);
}
}
function handleBlur() {
touched = true;
const normalized = normalizeEmail(value);
if (normalized !== value) {
value = normalized;
onvaluechange?.(normalized);
}
validate(value);
onblur?.();
}
function validate(val: string) {
if (!val) {
if (required) {
error = 'Email is required';
onerrorchange?.(error);
} else {
error = '';
onerrorchange?.('');
}
return;
}
if (!isValidEmail(val)) {
error = 'Invalid email address';
onerrorchange?.(error);
} else {
error = '';
onerrorchange?.('');
}
}
</script>
<div class="space-y-1.5">
<Input
{id}
type="email"
inputmode="email"
autocomplete="email"
{placeholder}
{value}
oninput={handleInput}
onblur={handleBlur}
{disabled}
aria-invalid={!!error || undefined}
class={className}
/>
{#if error}
<p class="text-sm text-red-500">{error}</p>
{/if}
</div>
@@ -0,0 +1,3 @@
import EmailInput from './EmailInput.svelte';
export { EmailInput };
+27
View File
@@ -0,0 +1,27 @@
/**
* Normalizes an email: trims whitespace and lowercases.
*/
export function normalizeEmail(value: string): string {
return value.trim().toLowerCase();
}
/**
* Validates an email address.
* Checks: non-empty, max 254 chars, passes a basic RFC-aware regex
* and a mailto: URI test via HTMLInputElement.
*/
export function isValidEmail(email: string): boolean {
const trimmed = email.trim();
if (!trimmed) return false;
if (trimmed.length > 254) return false;
// Basic structural regex — no RFC 5322 full validation, but catches obvious junk
if (!/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(trimmed)) return false;
// Use the browser's built-in email validation via type=email
// (catches things like multiple @ signs)
const input = document.createElement('input');
input.type = 'email';
input.value = trimmed;
return input.checkValidity();
}