189 lines
4.8 KiB
Svelte
189 lines
4.8 KiB
Svelte
<script lang="ts">
|
|
import { isValueValid, validate, type ValidatorOptions } from '@repo/validate';
|
|
import Label from './Label.svelte';
|
|
|
|
let {
|
|
label,
|
|
length,
|
|
required,
|
|
name,
|
|
value = $bindable(''),
|
|
oncomplete,
|
|
onchange
|
|
}: {
|
|
label?: string;
|
|
length: number;
|
|
required?: boolean;
|
|
name: string;
|
|
value: string;
|
|
oncomplete?: (value: string) => void;
|
|
onchange?: (value: string) => void;
|
|
} = $props();
|
|
|
|
let hiddenInput: HTMLInputElement;
|
|
let valid: boolean = $state(true);
|
|
|
|
const validateOpts: ValidatorOptions = {
|
|
required: required,
|
|
length: length,
|
|
autovalOnInvalid: true
|
|
};
|
|
|
|
const inputs: HTMLInputElement[] = $state([]);
|
|
|
|
// firstEmptyInputIndex returns the index of the first empty input in the pin input
|
|
const firstEmptyInputIndex = () => {
|
|
for (let i = 0; i < inputs.length; i++) {
|
|
if (inputs[i].value === '') {
|
|
return i;
|
|
}
|
|
}
|
|
|
|
return inputs.length - 1;
|
|
};
|
|
|
|
// joinValue joins the values of the pin input into a single string
|
|
const joinValue = () => {
|
|
return inputs.map((input) => input.value).join('');
|
|
};
|
|
|
|
// updateHiddenInput updates the hidden input value with the current value of the pin input
|
|
const updateHiddenInput = () => {
|
|
console.log('updating hidden');
|
|
hiddenInput.value = value;
|
|
hiddenInput.dispatchEvent(new KeyboardEvent('keyup'));
|
|
};
|
|
|
|
// onfocusinput selects all the text in an input when it is focused
|
|
const onfocusinput = (index: number) => {
|
|
return () => inputs[index].select();
|
|
};
|
|
|
|
// onmousedown triggers focus, forces selection of text and prevents focus on blank inputs
|
|
const onmousedown = (index: number) => {
|
|
return (e: MouseEvent) => {
|
|
e.preventDefault();
|
|
|
|
let node = inputs[index];
|
|
if (node.value === '') node = inputs[firstEmptyInputIndex()];
|
|
|
|
node.focus();
|
|
node.select();
|
|
};
|
|
};
|
|
|
|
// onkeydowninput handles accessibility and editing
|
|
const onkeydowninput = (index: number) => {
|
|
return async (e: KeyboardEvent) => {
|
|
const node = inputs[index];
|
|
|
|
// allow pasting into fields
|
|
if (e.key === 'v' && (e.ctrlKey || e.metaKey)) {
|
|
const clipboardData = await navigator.clipboard.readText();
|
|
const clipboardValue = clipboardData.replace(/\D/g, '').slice(0, length);
|
|
const clipboardValid = await isValueValid(clipboardValue, validateOpts);
|
|
|
|
console.log('pasting', clipboardValue, clipboardValid);
|
|
|
|
if (clipboardValid) {
|
|
value = clipboardValue;
|
|
inputs[inputs.length - 1].focus();
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// passthrough ctrl, cmd, and alt keys
|
|
if (e.ctrlKey || e.metaKey || e.altKey) {
|
|
return;
|
|
}
|
|
|
|
e.preventDefault(); // prevent default behaviour for all keys
|
|
|
|
// allow plain backspace and (forward) delete behaviour under some circumstances
|
|
if (
|
|
(e.key === 'Backspace' || e.key === 'Delete') &&
|
|
node.value !== '' &&
|
|
(index === inputs.length - 1 || inputs[index + 1].value === '')
|
|
) {
|
|
inputs[index].value = '';
|
|
value = joinValue();
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'Backspace' && node.value === '' && index > 0) {
|
|
inputs[index - 1].focus();
|
|
inputs[index - 1].value = '';
|
|
value = joinValue();
|
|
} else if ((e.key === 'ArrowLeft' || e.key === 'ArrowDown') && index > 0) {
|
|
inputs[index - 1].focus();
|
|
} else if (
|
|
(e.key === 'ArrowRight' || e.key === 'ArrowUp') &&
|
|
index < inputs.length - 1 &&
|
|
node.value !== ''
|
|
) {
|
|
inputs[index + 1].focus();
|
|
} else if (e.key !== 'Backspace' && /^\d$/.test(e.key)) {
|
|
if (
|
|
node.selectionStart === null ||
|
|
node.selectionEnd === null ||
|
|
(node.value !== '' && Math.abs(node.selectionEnd - node.selectionStart) === 0)
|
|
)
|
|
return;
|
|
|
|
inputs[index].value = e.key;
|
|
value = joinValue();
|
|
|
|
if (index < inputs.length - 1) {
|
|
inputs[index + 1].focus();
|
|
}
|
|
}
|
|
};
|
|
};
|
|
|
|
$effect(() => {
|
|
if (value) updateHiddenInput();
|
|
if (onchange !== undefined) onchange(value);
|
|
if (oncomplete !== undefined && value.length === length) oncomplete(value);
|
|
});
|
|
</script>
|
|
|
|
<input
|
|
class="hidden"
|
|
use:validate={validateOpts}
|
|
onvalidate={(e) => {
|
|
valid = e.detail.valid;
|
|
}}
|
|
bind:this={hiddenInput}
|
|
/>
|
|
|
|
{#if label}
|
|
<Label bigError={!valid} for={name}>{label}</Label>
|
|
{/if}
|
|
|
|
<div>
|
|
<div class="flex gap-4">
|
|
{#each { length: length } as _, i}
|
|
<input
|
|
type="text"
|
|
class={[
|
|
'px[1.125rem] w-[5ch] rounded-sm pb-3.5 pt-4 transition-colors',
|
|
'text-center align-middle font-mono font-normal placeholder:font-normal',
|
|
'border-accent dark:border-accent/50 border',
|
|
'text-text placeholder:text-text/30 dark:text-background dark:placeholder:text-background/30',
|
|
'dark:bg-text-800 bg-white dark:sm:bg-slate-800',
|
|
!valid && i >= value.length && 'border-red-500!'
|
|
]}
|
|
value={value[i] || ''}
|
|
{required}
|
|
maxlength="1"
|
|
onfocus={onfocusinput(i)}
|
|
onmousedown={onmousedown(i)}
|
|
onkeydown={onkeydowninput(i)}
|
|
bind:this={inputs[i]}
|
|
placeholder="0"
|
|
/>
|
|
{/each}
|
|
</div>
|
|
</div>
|