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:
@@ -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 };
|
||||||
@@ -22,18 +22,18 @@ export function isValidUKPhone(phone: string): boolean {
|
|||||||
export function formatPhoneDisplay(value: string): string {
|
export function formatPhoneDisplay(value: string): string {
|
||||||
const clean = normalisePhoneInput(value);
|
const clean = normalisePhoneInput(value);
|
||||||
if (clean.startsWith('+44')) {
|
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 <= 4) return '+44 ' + digits;
|
||||||
if (digits.length <= 8) return '+44 ' + digits.slice(0, 4) + ' ' + digits.slice(4);
|
if (digits.length <= 8) return '+44 ' + digits.slice(0, 4) + ' ' + digits.slice(4);
|
||||||
return '+44 ' + digits.slice(0, 4) + ' ' + digits.slice(4, 10);
|
return '+44 ' + digits.slice(0, 4) + ' ' + digits.slice(4, 10);
|
||||||
}
|
}
|
||||||
if (clean.startsWith('0')) {
|
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 <= 4) return '0' + digits;
|
||||||
if (digits.length <= 7) return '0' + digits.slice(0, 4) + ' ' + digits.slice(4);
|
if (digits.length <= 7) return '0' + digits.slice(0, 4) + ' ' + digits.slice(4);
|
||||||
return '0' + digits.slice(0, 4) + ' ' + digits.slice(4, 10);
|
return '0' + digits.slice(0, 4) + ' ' + digits.slice(4, 10);
|
||||||
}
|
}
|
||||||
return clean;
|
return clean.slice(0, 15); // fallback limit
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user