melt
Version:
The next generation of Melt UI. Built for Svelte 5.
255 lines (254 loc) • 9.59 kB
JavaScript
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);
}
},
};
}
}