UNPKG

melt

Version:

The next generation of Melt UI. Built for Svelte 5.

255 lines (254 loc) 9.59 kB
import { Synced } from "../Synced.svelte"; import { dataAttr, disabledAttr } from "../utils/attribute"; import { inBrowser } from "../utils/browser"; import { extract } from "../utils/extract"; import { createBuilderMetadata } from "../utils/identifiers"; import { isHtmlInputElement } from "../utils/is"; const { dataAttrs, dataSelectors, createIds } = createBuilderMetadata("pin-input", [ "root", "input", ]); function validateInput(char, type) { switch (type) { case "alphanumeric": return /^[a-zA-Z0-9]$/.test(char); case "numeric": return /^[0-9]$/.test(char); case "text": return true; } } function setInputSelectionRange(input, start, end) { setTimeout(() => { if (input.value.length === 0) return; if (input.selectionStart === start && input.selectionEnd === end) return; input.setSelectionRange(start, end); }); } export class PinInput { #ids = createIds(); /* Props */ #props; maxLength = $derived(extract(this.#props.maxLength, 4)); placeholder = $derived(extract(this.#props.placeholder, "○")); disabled = $derived(extract(this.#props.disabled, false)); mask = $derived(extract(this.#props.mask, false)); type = $derived(extract(this.#props.type, "text")); allowPaste = $derived(extract(this.#props.allowPaste, true)); /* State */ #value; #focusedIndex = $state(-1); isFilled = $derived(this.value.length === this.maxLength); constructor(props = {}) { this.#value = new Synced({ value: props.value, onChange: props.onValueChange, defaultValue: "", equalityCheck: true, }); this.#props = props; } #getInputEls() { if (!inBrowser()) return []; const rootEl = document.getElementById(this.#ids.root); if (!rootEl) return []; return [...rootEl.querySelectorAll(dataSelectors.input)].filter(isHtmlInputElement); } get value() { return this.#value.current; } set value(value) { const prev = this.#value.current; this.#value.current = value; // set values in inputs const inputs = this.#getInputEls(); inputs.forEach((input, index) => { input.value = value[index] ?? ""; }); const completed = prev.length !== value.length && value.length === this.maxLength; if (completed) { this.#props.onComplete?.(value); } } /** The root element's props. */ get root() { return { [dataAttrs.root]: "", id: this.#ids.root, "data-complete": dataAttr(this.isFilled), }; } /** An array of props that should be spread to the input elements. */ get inputs() { return Array(this.maxLength) .fill(0) .map((_, index) => this.#getInput(index)); } #deleteCharAtIndex(index) { this.value = this.value.slice(0, index) + this.value.slice(index + 1); } #addCharAtIndex(char, index) { this.value = this.value.slice(0, index) + char + this.value.slice(index + 1); } #getInput(index) { const currValue = this.value[index]; const isFilled = currValue !== undefined; const isFocused = this.#focusedIndex === index; const isLast = index === this.maxLength - 1; const canFocus = (this.isFilled && isLast) || index === this.value.length; const onpaste = (pasted) => { if (!this.allowPaste) return; const inputs = this.#getInputEls(); if (!inputs.length) return; const focusedIndex = Math.max(this.#focusedIndex, 0); const initialIndex = pasted.length >= inputs.length ? 0 : focusedIndex; const lastIndex = Math.min(initialIndex + pasted.length, inputs.length); const valid = pasted.split("").every((char) => validateInput(char, this.type)); if (!valid) { this.#props.onError?.({ method: "paste", message: `Input did not match the type ${this.type}`, }); return; } for (let i = initialIndex; i < lastIndex; i++) { const input = inputs[i]; if (!input) continue; input.value = pasted[i - initialIndex] ?? ""; this.#addCharAtIndex(pasted[i - initialIndex] ?? "", i); } inputs[lastIndex]?.focus(); }; return { [dataAttrs.input]: "", placeholder: isFocused ? undefined : this.placeholder, disabled: disabledAttr(this.disabled), type: this.mask ? "password" : "text", "data-filled": dataAttr(isFilled), tabindex: canFocus ? 0 : -1, inputmode: this.type === "numeric" ? "numeric" : "text", style: canFocus && isFocused && !isFilled ? undefined : "caret-color: transparent;", onkeydown: (e) => { const el = e.target; if (!isHtmlInputElement(el)) { return; } const inputs = this.#getInputEls(); switch (e.key) { case "ArrowLeft": { e.preventDefault(); inputs[index - 1]?.focus(); break; } case "ArrowRight": { if (!this.value[index]) return; e.preventDefault(); inputs.at(index + 1)?.focus(); break; } case "Home": { e.preventDefault(); inputs[0]?.focus(); break; } case "End": { e.preventDefault(); const lastFocusableIndex = Math.min(this.value.length, inputs.length - 1); inputs[lastFocusableIndex]?.focus(); break; } case "Backspace": { e.preventDefault(); if (this.value[index]) { this.#deleteCharAtIndex(index); } else { this.#deleteCharAtIndex(index - 1); setTimeout(() => inputs[index - 1]?.focus()); } break; } } }, onpointerdown: (e) => { const el = e.target; if (!isHtmlInputElement(el)) { return; } setInputSelectionRange(el, 1, 1); if (this.value[index]) return; const inputs = this.#getInputEls(); // Set timeout so deps can change, and canFocus is re-evaluated. setTimeout(() => inputs[this.value.length]?.focus()); }, onpointerup: (e) => { const el = e.target; if (!isHtmlInputElement(el)) { return; } setInputSelectionRange(el, 1, 1); }, oninput: (e) => { const el = e.target; if (!isHtmlInputElement(el)) { return; } e.preventDefault(); const prev = currValue; const inputted = prev ? el.value.slice(prev.length) : el.value; if (inputted.length === 1) { const char = el.value.slice(el.value.length - 1); if (!validateInput(char, this.type)) { this.#props.onError?.({ method: "input", message: `Input did not match the type ${this.type}`, }); el.value = el.value.slice(0, -1); return; } el.value = char; this.#addCharAtIndex(char, index); const inputs = this.#getInputEls(); const currIndex = inputs.indexOf(el); // Set timeout so deps can change, and canFocus is re-evaluated. setTimeout(() => inputs[currIndex + 1]?.focus()); } else { if (this.#props.onPaste) { this.#props.onPaste(inputted); } else { onpaste(inputted); } } }, onfocus: () => { this.#focusedIndex = index; }, onblur: () => { this.#focusedIndex = -1; }, onpaste: (e) => { e.preventDefault(); const pasted = e.clipboardData?.getData("text"); if (!pasted) return; if (this.#props.onPaste) { this.#props.onPaste(pasted); } else { onpaste(pasted); } }, }; } }