VOtpInput.mjs 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. import { mergeProps as _mergeProps, createVNode as _createVNode, Fragment as _Fragment } from "vue";
  2. // Styles
  3. import "./VOtpInput.css";
  4. // Components
  5. import { makeVFieldProps, VField } from "../VField/VField.mjs";
  6. import { VOverlay } from "../VOverlay/VOverlay.mjs";
  7. import { VProgressCircular } from "../VProgressCircular/VProgressCircular.mjs"; // Composables
  8. import { provideDefaults } from "../../composables/defaults.mjs";
  9. import { makeDimensionProps, useDimension } from "../../composables/dimensions.mjs";
  10. import { makeFocusProps, useFocus } from "../../composables/focus.mjs";
  11. import { useLocale } from "../../composables/locale.mjs";
  12. import { useProxiedModel } from "../../composables/proxiedModel.mjs"; // Utilities
  13. import { computed, nextTick, ref, watch } from 'vue';
  14. import { filterInputAttrs, focusChild, genericComponent, only, propsFactory, useRender } from "../../util/index.mjs"; // Types
  15. // Types
  16. export const makeVOtpInputProps = propsFactory({
  17. autofocus: Boolean,
  18. divider: String,
  19. focusAll: Boolean,
  20. label: {
  21. type: String,
  22. default: '$vuetify.input.otp'
  23. },
  24. length: {
  25. type: [Number, String],
  26. default: 6
  27. },
  28. modelValue: {
  29. type: [Number, String],
  30. default: undefined
  31. },
  32. placeholder: String,
  33. type: {
  34. type: String,
  35. default: 'number'
  36. },
  37. ...makeDimensionProps(),
  38. ...makeFocusProps(),
  39. ...only(makeVFieldProps({
  40. variant: 'outlined'
  41. }), ['baseColor', 'bgColor', 'class', 'color', 'disabled', 'error', 'loading', 'rounded', 'style', 'theme', 'variant'])
  42. }, 'VOtpInput');
  43. export const VOtpInput = genericComponent()({
  44. name: 'VOtpInput',
  45. props: makeVOtpInputProps(),
  46. emits: {
  47. finish: val => true,
  48. 'update:focused': val => true,
  49. 'update:modelValue': val => true
  50. },
  51. setup(props, _ref) {
  52. let {
  53. attrs,
  54. emit,
  55. slots
  56. } = _ref;
  57. const {
  58. dimensionStyles
  59. } = useDimension(props);
  60. const {
  61. isFocused,
  62. focus,
  63. blur
  64. } = useFocus(props);
  65. const model = useProxiedModel(props, 'modelValue', '', val => val == null ? [] : String(val).split(''), val => val.join(''));
  66. const {
  67. t
  68. } = useLocale();
  69. const length = computed(() => Number(props.length));
  70. const fields = computed(() => Array(length.value).fill(0));
  71. const focusIndex = ref(-1);
  72. const contentRef = ref();
  73. const inputRef = ref([]);
  74. const current = computed(() => inputRef.value[focusIndex.value]);
  75. function onInput() {
  76. // The maxlength attribute doesn't work for the number type input, so the text type is used.
  77. // The following logic simulates the behavior of a number input.
  78. if (isValidNumber(current.value.value)) {
  79. current.value.value = '';
  80. return;
  81. }
  82. const array = model.value.slice();
  83. const value = current.value.value;
  84. array[focusIndex.value] = value;
  85. let target = null;
  86. if (focusIndex.value > model.value.length) {
  87. target = model.value.length + 1;
  88. } else if (focusIndex.value + 1 !== length.value) {
  89. target = 'next';
  90. }
  91. model.value = array;
  92. if (target) focusChild(contentRef.value, target);
  93. }
  94. function onKeydown(e) {
  95. const array = model.value.slice();
  96. const index = focusIndex.value;
  97. let target = null;
  98. if (!['ArrowLeft', 'ArrowRight', 'Backspace', 'Delete'].includes(e.key)) return;
  99. e.preventDefault();
  100. if (e.key === 'ArrowLeft') {
  101. target = 'prev';
  102. } else if (e.key === 'ArrowRight') {
  103. target = 'next';
  104. } else if (['Backspace', 'Delete'].includes(e.key)) {
  105. array[focusIndex.value] = '';
  106. model.value = array;
  107. if (focusIndex.value > 0 && e.key === 'Backspace') {
  108. target = 'prev';
  109. } else {
  110. requestAnimationFrame(() => {
  111. inputRef.value[index]?.select();
  112. });
  113. }
  114. }
  115. requestAnimationFrame(() => {
  116. if (target != null) {
  117. focusChild(contentRef.value, target);
  118. }
  119. });
  120. }
  121. function onPaste(index, e) {
  122. e.preventDefault();
  123. e.stopPropagation();
  124. const clipboardText = e?.clipboardData?.getData('Text').slice(0, length.value) ?? '';
  125. if (isValidNumber(clipboardText)) return;
  126. model.value = clipboardText.split('');
  127. inputRef.value?.[index].blur();
  128. }
  129. function reset() {
  130. model.value = [];
  131. }
  132. function onFocus(e, index) {
  133. focus();
  134. focusIndex.value = index;
  135. }
  136. function onBlur() {
  137. blur();
  138. focusIndex.value = -1;
  139. }
  140. function isValidNumber(value) {
  141. return props.type === 'number' && /[^0-9]/g.test(value);
  142. }
  143. provideDefaults({
  144. VField: {
  145. color: computed(() => props.color),
  146. bgColor: computed(() => props.color),
  147. baseColor: computed(() => props.baseColor),
  148. disabled: computed(() => props.disabled),
  149. error: computed(() => props.error),
  150. variant: computed(() => props.variant)
  151. }
  152. }, {
  153. scoped: true
  154. });
  155. watch(model, val => {
  156. if (val.length === length.value) emit('finish', val.join(''));
  157. }, {
  158. deep: true
  159. });
  160. watch(focusIndex, val => {
  161. if (val < 0) return;
  162. nextTick(() => {
  163. inputRef.value[val]?.select();
  164. });
  165. });
  166. useRender(() => {
  167. const [rootAttrs, inputAttrs] = filterInputAttrs(attrs);
  168. return _createVNode("div", _mergeProps({
  169. "class": ['v-otp-input', {
  170. 'v-otp-input--divided': !!props.divider
  171. }, props.class],
  172. "style": [props.style]
  173. }, rootAttrs), [_createVNode("div", {
  174. "ref": contentRef,
  175. "class": "v-otp-input__content",
  176. "style": [dimensionStyles.value]
  177. }, [fields.value.map((_, i) => _createVNode(_Fragment, null, [props.divider && i !== 0 && _createVNode("span", {
  178. "class": "v-otp-input__divider"
  179. }, [props.divider]), _createVNode(VField, {
  180. "focused": isFocused.value && props.focusAll || focusIndex.value === i,
  181. "key": i
  182. }, {
  183. ...slots,
  184. loader: undefined,
  185. default: () => {
  186. return _createVNode("input", {
  187. "ref": val => inputRef.value[i] = val,
  188. "aria-label": t(props.label, i + 1),
  189. "autofocus": i === 0 && props.autofocus,
  190. "autocomplete": "one-time-code",
  191. "class": ['v-otp-input__field'],
  192. "disabled": props.disabled,
  193. "inputmode": props.type === 'number' ? 'numeric' : 'text',
  194. "min": props.type === 'number' ? 0 : undefined,
  195. "maxlength": "1",
  196. "placeholder": props.placeholder,
  197. "type": props.type === 'number' ? 'text' : props.type,
  198. "value": model.value[i],
  199. "onInput": onInput,
  200. "onFocus": e => onFocus(e, i),
  201. "onBlur": onBlur,
  202. "onKeydown": onKeydown,
  203. "onPaste": event => onPaste(i, event)
  204. }, null);
  205. }
  206. })])), _createVNode("input", _mergeProps({
  207. "class": "v-otp-input-input",
  208. "type": "hidden"
  209. }, inputAttrs, {
  210. "value": model.value.join('')
  211. }), null), _createVNode(VOverlay, {
  212. "contained": true,
  213. "content-class": "v-otp-input__loader",
  214. "model-value": !!props.loading,
  215. "persistent": true
  216. }, {
  217. default: () => [slots.loader?.() ?? _createVNode(VProgressCircular, {
  218. "color": typeof props.loading === 'boolean' ? undefined : props.loading,
  219. "indeterminate": true,
  220. "size": "24",
  221. "width": "2"
  222. }, null)]
  223. }), slots.default?.()])]);
  224. });
  225. return {
  226. blur: () => {
  227. inputRef.value?.some(input => input.blur());
  228. },
  229. focus: () => {
  230. inputRef.value?.[0].focus();
  231. },
  232. reset,
  233. isFocused
  234. };
  235. }
  236. });
  237. //# sourceMappingURL=VOtpInput.mjs.map