import { addMeltEventListener, createElHelpers, disabledAttr, executeCallbacks, isBrowser, isHTMLElement, isHTMLInputElement, last, makeElement, next, omit, overridable, prev, toWritableStores, } from '../../internal/helpers/index.js'; import { tick } from 'svelte'; import { derived, readonly, writable } from 'svelte/store'; import { generateIds } from '../../internal/helpers/id.js'; import { createHiddenInput } from '../hidden-input/create.js'; const prefix = 'pin-input'; const { name, selector } = createElHelpers(prefix); const getInputs = (node) => { const rootEl = node.closest(selector()); if (!isHTMLElement(rootEl)) { return { inputs: null, el: node, elIndex: -1 }; } const inputs = Array.from(rootEl.querySelectorAll(selector('input'))).filter((input) => isHTMLInputElement(input)); return { elIndex: inputs.indexOf(node), inputs, }; }; const defaults = { placeholder: '○', disabled: false, type: 'text', name: undefined, defaultValue: [], }; export const pinInputIdParts = ['root']; export function createPinInput(props) { const withDefaults = { ...defaults, ...props }; const options = toWritableStores(omit(withDefaults, 'value', 'ids')); const { placeholder, disabled, type, name: nameStore } = options; const valueWritable = withDefaults.value ?? writable(withDefaults.defaultValue); const value = overridable(valueWritable, withDefaults?.onValueChange); const valueStr = derived(value, (v) => v.join('')); const ids = toWritableStores({ ...generateIds(pinInputIdParts), ...withDefaults.ids }); const root = makeElement(name(), { stores: [value, ids.root], returned: ([$value, $rootId]) => { return { id: $rootId, 'data-complete': $value.length && $value.every((v) => v.length > 0) ? '' : undefined, }; }, }); let index = 0; const getTotalItems = () => { if (!isBrowser) return Infinity; const rootEl = document.getElementById(ids.root.get()); if (!rootEl) return Infinity; const inputs = Array.from(rootEl.querySelectorAll(selector('input'))); return inputs.length; }; const input = makeElement(name('input'), { stores: [value, placeholder, disabled, type], returned: ([$value, $placeholder, $disabled, $type]) => { return () => { const totalItems = getTotalItems(); const currIndex = index % totalItems; index = (index + 1) % totalItems; const currValue = $value[currIndex] ?? ''; return { 'data-complete': $value.length && $value.every((v) => v.length > 0) ? '' : undefined, placeholder: $placeholder, disabled: disabledAttr($disabled), type: $type, value: currValue, }; }; }, action: (node) => { const { elIndex } = getInputs(node); value.update((v) => { v[elIndex] = node.value; return v; }); const unsub = executeCallbacks(addMeltEventListener(node, 'keydown', (e) => { const { inputs, elIndex } = getInputs(node); if (!inputs) return; if (e.key === 'Backspace') { e.preventDefault(); if (node.value) { node.value = ''; tick().then(() => (node.placeholder = '')); value.set(inputs.map((input) => input.value.slice(-1) ?? undefined)); } else { const prevEl = prev(inputs, elIndex, false); prevEl.focus(); prevEl.value = ''; tick().then(() => (prevEl.placeholder = '')); value.set(inputs.map((input) => input.value.slice(-1) ?? undefined)); } } if (e.key === 'Delete') { e.preventDefault(); node.value = ''; tick().then(() => (node.placeholder = '')); value.set(inputs.map((input) => input.value.slice(-1) ?? undefined)); } if (e.key === 'ArrowLeft') { e.preventDefault(); const prevEl = prev(inputs, elIndex, false); prevEl.focus(); } if (e.key === 'ArrowRight') { e.preventDefault(); const nextEl = next(inputs, elIndex, false); nextEl.focus(); } if (e.key === 'Home') { e.preventDefault(); inputs[0].focus(); } if (e.key === 'End') { e.preventDefault(); last(inputs).focus(); } }), addMeltEventListener(node, 'input', (e) => { const { inputs, elIndex } = getInputs(node); if (!inputs) return; const getInputted = (el) => { const $value = value.get(); const prevElValue = $value[elIndex]; const selectionStart = el.selectionStart ?? 1; if (!prevElValue) return el.value; return selectionStart > 1 ? el.value.slice(1) : el.value.slice(0, Math.max(el.value.length - 2, 1)); }; const inputted = getInputted(node); const inputEvent = e; if (inputEvent.inputType === 'insertFromPaste') { return; } // Only allow 1 character, get last node.value = inputted.slice(-1); if (node.value.length !== 0) { const nextEl = next(inputs, elIndex, false); nextEl.focus(); } value.set(inputs.map((input) => input.value.slice(-1) ?? undefined)); }), addMeltEventListener(node, 'paste', (e) => { e.preventDefault(); const { inputs, elIndex } = getInputs(node); if (!inputs) return; const inputEvent = e; const clipboardData = inputEvent.clipboardData; if (!clipboardData) return; const pasted = clipboardData.getData('text'); const initialIndex = pasted.length >= inputs.length ? 0 : elIndex; const lastIndex = Math.min(initialIndex + pasted.length, inputs.length); for (let i = initialIndex; i < lastIndex; i++) { const input = inputs[i]; input.value = pasted[i - initialIndex]; input.focus(); } inputs[lastIndex]?.focus(); value.set(inputs.map((input) => input.value.slice(-1) ?? undefined)); }), addMeltEventListener(node, 'change', () => { const { inputs } = getInputs(node); if (!inputs) return; value.set(inputs.map((input) => input.value.slice(-1) ?? undefined)); }), addMeltEventListener(node, 'focus', () => { node.setSelectionRange(1, 1); node.placeholder = ''; tick().then(() => { node.placeholder = ''; }); }), addMeltEventListener(node, 'blur', () => { node.placeholder = placeholder.get(); })); return { destroy() { unsub(); }, }; }, }); const hiddenInput = createHiddenInput({ value: valueStr, disabled, name: nameStore, prefix, }); const clear = () => { value.update((v) => { v.forEach((_, i) => (v[i] = '')); return v; }); }; return { ids, elements: { root, input, hiddenInput, }, states: { value, valueStr: readonly(valueStr), }, helpers: { clear, }, options, }; }