UNPKG

vuetify

Version:

Vue Material Component Framework

358 lines (354 loc) 12 kB
import { createVNode as _createVNode, Fragment as _Fragment, createElementVNode as _createElementVNode, mergeProps as _mergeProps, normalizeStyle as _normalizeStyle } from "vue"; // Styles import "./VOtpInput.css"; // Components import { VOtpField } from "./VOtpField.js"; import { VOtpGroup } from "./VOtpGroup.js"; import { VOtpSeparator } from "./VOtpSeparator.js"; import { makeVFieldProps } from "../VField/VField.js"; import { VOverlay } from "../VOverlay/VOverlay.js"; import { VProgressCircular } from "../VProgressCircular/VProgressCircular.js"; // Composables import { useOtpInput } from "./useOtpInput.js"; import { provideDefaults } from "../../composables/defaults.js"; import { makeDensityProps, useDensity } from "../../composables/density.js"; import { makeDimensionProps, useDimension } from "../../composables/dimensions.js"; import { makeFocusProps, useFocus } from "../../composables/focus.js"; import { useIntersectionObserver } from "../../composables/intersectionObserver.js"; import { useLocale, useRtl } from "../../composables/locale.js"; import { useProxiedModel } from "../../composables/proxiedModel.js"; import { useToggleScope } from "../../composables/toggleScope.js"; // Utilities import { effectScope, provide, ref, toRef, watch, watchEffect } from 'vue'; import { filterInputAttrs, genericComponent, pick, propsFactory, useRender } from "../../util/index.js"; // Shared import { VOtpInputSymbol } from "./shared.js"; // Types export { VOtpInputSymbol } from "./shared.js"; export const makeVOtpInputProps = propsFactory({ autofocus: Boolean, divider: String, focusAll: Boolean, merged: Boolean, label: { type: String, default: '$vuetify.input.otp' }, length: { type: [Number, String], default: 6 }, masked: Boolean, modelValue: { type: [Number, String], default: undefined }, pattern: { type: [String, Object], default: undefined }, placeholder: String, type: { type: String, default: 'number' }, ...makeDensityProps(), ...makeDimensionProps(), ...makeFocusProps(), ...pick(makeVFieldProps({ variant: 'outlined' }), ['baseColor', 'bgColor', 'class', 'color', 'disabled', 'error', 'loading', 'rounded', 'style', 'theme', 'variant']) }, 'VOtpInput'); export const VOtpInput = genericComponent()({ name: 'VOtpInput', props: makeVOtpInputProps(), emits: { finish: value => true, 'update:focused': value => true, 'update:modelValue': value => true }, setup(props, { attrs, emit, slots }) { const { densityClasses } = useDensity(props); const { dimensionStyles } = useDimension(props); const { isFocused } = useFocus(props); const { t } = useLocale(); const { isRtl } = useRtl(); const model = useProxiedModel(props, 'modelValue', '', val => val == null ? '' : String(val)); const inputRef = ref(); const length = toRef(() => Number(props.length)); let focusAtPending = false; const otp = useOtpInput({ value: model, length, pattern: () => props.pattern, type: () => props.type, masked: () => props.masked, placeholder: () => props.placeholder, isFocused }); function applySelection() { const input = inputRef.value; const selection = otp.selection.value; if (!input || !selection) return; input.setSelectionRange(selection.start, selection.end, selection.direction); } function syncDOM() { const input = inputRef.value; if (!input) return; if (input.value !== otp.value.value) input.value = otp.value.value; applySelection(); } function onSelectionChange() { const input = inputRef.value; if (!input) { otp.clearSelection(); return; } const result = otp.syncSelection({ value: input.value, selectionStart: input.selectionStart, selectionEnd: input.selectionEnd, selectionDirection: input.selectionDirection, // Slot count, not `input.maxLength` (code units). maxLength: length.value }); if (!result) return; if (input.selectionStart !== result.start || input.selectionEnd !== result.end) { input.setSelectionRange(result.start, result.end, result.direction); } } function onInput(e) { const ev = e; const target = e.target; const composing = ev.isComposing || otp.isComposing.value; // CJK composition renders via the overlay until compositionend. if (composing && otp.isImeText(target.value)) return; // Non-CJK composition (dead keys, Samsung predictive, Chrome OTP autofill) // commits into the model now; don't end composition early or selection // will advance past the model length on the next selectionchange. const next = otp.setValue(target.value); if (target.value !== next) target.value = next; } function onCompositionstart() { otp.startComposition(); } function onCompositionupdate(e) { otp.updateComposition(e.data ?? ''); } function onCompositionend(e) { otp.endComposition(); onInput(e); } function onFocus() { isFocused.value = true; if (focusAtPending) return; if (!inputRef.value) return; otp.selectAtEnd(); applySelection(); } function onBlur() { isFocused.value = false; otp.clearSelection(); } function onKeydown(e) { if (e.shiftKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) { // Extend with our own anchor so the originally-selected slot stays included. const direction = (e.key === 'ArrowLeft' ? -1 : 1) * (isRtl.value ? -1 : 1); if (otp.extendSelection(direction)) { e.preventDefault(); syncDOM(); } return; } if (e.key !== 'Backspace' && e.key !== 'Delete') return; if (!e.metaKey && !e.ctrlKey && !e.altKey) return; e.preventDefault(); otp.bulkDelete(e.key === 'Backspace'); syncDOM(); } function onBeforeinput(e) { if (e.inputType === 'insertText' && e.data && otp.effectivePattern.value && !otp.effectivePattern.value.test(e.data)) { e.preventDefault(); return; } if (e.inputType === 'deleteContentForward') { e.preventDefault(); const input = inputRef.value; if (!input) return; const selection = otp.selection.value; const start = selection?.start ?? 0; const end = selection?.end ?? input.value.length; otp.deleteRange(start, end); syncDOM(); return; } const isBackward = ['deleteWordBackward', 'deleteSoftLineBackward', 'deleteHardLineBackward'].includes(e.inputType); const isForward = ['deleteWordForward', 'deleteSoftLineForward', 'deleteHardLineForward'].includes(e.inputType); if (!isBackward && !isForward) return; e.preventDefault(); otp.bulkDelete(isBackward); syncDOM(); } function onPaste(e) { e.preventDefault(); const input = inputRef.value; if (!input) return; const text = e.clipboardData?.getData('text/plain').trim() ?? ''; const selection = otp.selection.value; otp.insert(text, { start: selection?.start ?? 0, end: selection?.end ?? input.value.length }); syncDOM(); } function focusAt(index) { const input = inputRef.value; if (!input) return; focusAtPending = true; input.focus(); focusAtPending = false; otp.selectSlot(index); applySelection(); } // selectionchange is not in InputHTMLAttributes types watch(inputRef, (input, _, onCleanup) => { if (!input) return; input.addEventListener('selectionchange', onSelectionChange); onCleanup(() => input.removeEventListener('selectionchange', onSelectionChange)); }, { immediate: true }); useToggleScope(() => props.autofocus, () => { const intersectScope = effectScope(); intersectScope.run(() => { const { intersectionRef, isIntersecting } = useIntersectionObserver(); watchEffect(() => { intersectionRef.value = inputRef.value; }); watch(isIntersecting, v => { if (!v) return; intersectionRef.value?.focus(); intersectScope.stop(); }); }); }); watch(model, val => { if (otp.isComposing.value) return; if (val.length === length.value) emit('finish', val); }); provideDefaults({ VField: { color: toRef(() => props.color), bgColor: toRef(() => props.color), baseColor: toRef(() => props.baseColor), disabled: toRef(() => props.disabled), error: toRef(() => props.error), variant: toRef(() => props.variant), rounded: toRef(() => props.rounded) } }, { scoped: true }); provide(VOtpInputSymbol, { otpSlots: otp.slots, isFocused, focusAll: toRef(() => props.focusAll), divider: toRef(() => props.divider), merged: toRef(() => props.merged), focusAt }); useRender(() => { const [rootAttrs, inputAttrs] = filterInputAttrs(attrs); return _createElementVNode("div", _mergeProps({ "class": ['v-otp-input', { 'v-otp-input--divided': !!props.divider }, densityClasses.value, props.class], "style": [props.style] }, rootAttrs), [_createElementVNode("div", { "class": "v-otp-input__content", "style": _normalizeStyle([dimensionStyles.value]), "dir": isRtl.value ? 'rtl' : 'ltr' }, [slots.fields ? slots.fields() : props.merged ? _createVNode(VOtpGroup, { "merged": true }, { default: () => [Array.from({ length: length.value }, (_, i) => _createVNode(VOtpField, { "index": i, "key": i }, null))] }) : Array.from({ length: length.value }, (_, i) => _createElementVNode(_Fragment, null, [(props.divider || slots.divider) && i !== 0 && _createVNode(VOtpSeparator, { "key": `d-${i}` }, { default: () => [slots.divider?.({ index: i - 1 }) ?? props.divider] }), _createVNode(VOtpField, { "index": i, "key": i }, null)])), _createElementVNode("input", _mergeProps({ "ref": inputRef, "class": "v-otp-input__input", "type": "text", "inputmode": otp.inputMode.value, "dir": isRtl.value ? 'rtl' : 'ltr', "autocomplete": "one-time-code", "autocorrect": "off", "autocapitalize": "off", "spellcheck": false, "disabled": props.disabled, "aria-label": t(props.label), "value": model.value }, inputAttrs, { "onInput": onInput, "onKeydown": onKeydown, "onBeforeinput": onBeforeinput, "onFocus": onFocus, "onBlur": onBlur, "onPaste": onPaste, "onCompositionstart": onCompositionstart, "onCompositionupdate": onCompositionupdate, "onCompositionend": onCompositionend }), null), _createVNode(VOverlay, { "contained": true, "contentClass": "v-otp-input__loader", "modelValue": !!props.loading, "persistent": true }, { default: () => [slots.loader?.() ?? _createVNode(VProgressCircular, { "color": typeof props.loading === 'boolean' ? undefined : props.loading, "indeterminate": true, "size": "24", "width": "2" }, null)] }), slots.default?.()])]); }); return { blur: () => { inputRef.value?.blur(); }, focus: () => { inputRef.value?.focus(); }, reset: otp.reset, isFocused }; } }); export { useOtpInput, OtpInputPatterns } from "./useOtpInput.js"; //# sourceMappingURL=VOtpInput.js.map