feat(frontend): add PhoneInput component with UK validation

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-11 22:08:26 +01:00
co-authored by Sisyphus
parent 8fd42867a4
commit 1be82986a5
3 changed files with 109 additions and 3 deletions
@@ -0,0 +1,103 @@
<script lang="ts">
import { Input } from '$lib/components/ui/input/index.js';
import { isValidUKPhone, formatPhoneDisplay } from '$lib/utils/phone';
interface Props {
value?: string;
error?: string;
placeholder?: string;
required?: boolean;
disabled?: boolean;
class?: string;
id?: string;
onvaluechange?: (value: string) => void;
onerrorchange?: (error: string) => void;
}
let {
value = $bindable(''),
error = $bindable(''),
placeholder = '07123 456789 or +44 7123 456789',
required = false,
disabled = false,
class: className = '',
id = 'phone',
onvaluechange,
onerrorchange
}: Props = $props();
let touched = $state(false);
function handleKeyDown(e: KeyboardEvent) {
const target = e.target as HTMLInputElement;
const allowedKeys = ['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'];
if (allowedKeys.includes(e.key)) return;
if (e.key === '+') return; // allow + anywhere; normalisePhoneInput removes extras
if (!/^[0-9]$/.test(e.key)) {
e.preventDefault();
}
}
function normalizePhone(value: string): string {
let cleaned = value.replace(/[\s\-\(\)]/g, '');
if (cleaned.startsWith('07')) {
cleaned = '+44' + cleaned.substring(1);
}
return cleaned;
}
function handleInput(e: Event) {
const target = e.target as HTMLInputElement;
const formatted = formatPhoneDisplay(target.value);
value = formatted;
onvaluechange?.(formatted);
// Only show live validation after the field has been blurred at least once
if (touched) {
if (formatted && !isValidUKPhone(formatted)) {
error = 'Invalid UK phone number';
onerrorchange?.(error);
} else {
error = '';
onerrorchange?.('');
}
}
}
function handleBlur() {
touched = true;
if (value && !isValidUKPhone(value)) {
error = 'Invalid UK phone number';
onerrorchange?.(error);
} else {
error = '';
onerrorchange?.('');
if (value) {
const normalized = normalizePhone(value);
if (normalized !== value) {
value = normalized;
onvaluechange?.(normalized);
}
}
}
}
</script>
<div class="space-y-1.5">
<Input
{id}
type="text"
inputmode="tel"
{placeholder}
maxlength={15}
{value}
oninput={handleInput}
onblur={handleBlur}
onkeydown={handleKeyDown}
{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 PhoneInput from './PhoneInput.svelte';
export { PhoneInput };
+3 -3
View File
@@ -22,18 +22,18 @@ export function isValidUKPhone(phone: string): boolean {
export function formatPhoneDisplay(value: string): string {
const clean = normalisePhoneInput(value);
if (clean.startsWith('+44')) {
const digits = clean.slice(3);
const digits = clean.slice(3).slice(0, 10); // max 10 digits after +44
if (digits.length <= 4) return '+44 ' + digits;
if (digits.length <= 8) return '+44 ' + digits.slice(0, 4) + ' ' + digits.slice(4);
return '+44 ' + digits.slice(0, 4) + ' ' + digits.slice(4, 10);
}
if (clean.startsWith('0')) {
const digits = clean.slice(1);
const digits = clean.slice(1).slice(0, 10); // max 10 digits after 0
if (digits.length <= 4) return '0' + digits;
if (digits.length <= 7) return '0' + digits.slice(0, 4) + ' ' + digits.slice(4);
return '0' + digits.slice(0, 4) + ' ' + digits.slice(4, 10);
}
return clean;
return clean.slice(0, 15); // fallback limit
}
/**