Files
sui/components/PinInput.svelte
2025-04-13 07:56:23 -07:00

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>