|
| 1 | +<template> |
| 2 | + <div :class="rootClasses"> |
| 3 | + <template v-for="i in length" :key="i"> |
| 4 | + <div :class="cellClasses(i - 1)" aria-hidden="true"> |
| 5 | + {{ code[i - 1] }} |
| 6 | + <span |
| 7 | + v-if="i - 1 === activeIndex && !code[i - 1]" |
| 8 | + class="afcl-code-input-caret w-px h-6 bg-lightInputText dark:bg-darkInputText animate-pulse" |
| 9 | + /> |
| 10 | + </div> |
| 11 | + <span |
| 12 | + v-if="isGroupEnd(i)" |
| 13 | + class="afcl-code-input-separator w-3 h-0.5 shrink-0 rounded-full bg-lightInputBorder dark:bg-darkInputBorder" |
| 14 | + aria-hidden="true" |
| 15 | + /> |
| 16 | + </template> |
| 17 | + <!-- one real field over the drawn cells: autofill and paste fill the whole code, screen readers read one field --> |
| 18 | + <input |
| 19 | + ref="inputRef" |
| 20 | + v-bind="restAttrs" |
| 21 | + :value="code" |
| 22 | + type="text" |
| 23 | + inputmode="numeric" |
| 24 | + autocomplete="one-time-code" |
| 25 | + spellcheck="false" |
| 26 | + autocorrect="off" |
| 27 | + autocapitalize="off" |
| 28 | + :maxlength="length" |
| 29 | + :disabled="disabled" |
| 30 | + :aria-invalid="invalid || undefined" |
| 31 | + class="afcl-code-input-field absolute inset-0 z-10 w-full h-full p-0 border-0 bg-transparent text-base text-transparent caret-transparent outline-none cursor-text selection:bg-transparent disabled:cursor-not-allowed" |
| 32 | + @input="setCode(($event.target as HTMLInputElement).value)" |
| 33 | + @paste="onPaste" |
| 34 | + @focus="onFocus" |
| 35 | + @blur="onBlur" |
| 36 | + @click="moveCaretToEnd" |
| 37 | + @select="moveCaretToEnd" |
| 38 | + @keydown="onKeydown" |
| 39 | + /> |
| 40 | + </div> |
| 41 | +</template> |
| 42 | + |
| 43 | +<script setup lang="ts"> |
| 44 | +import { computed, nextTick, onBeforeUnmount, onMounted, ref, useAttrs } from 'vue'; |
| 45 | +import { type ClassNameValue } from 'tailwind-merge'; |
| 46 | +import { mergeClasses } from './classMerge'; |
| 47 | +
|
| 48 | +defineOptions({ inheritAttrs: false }); |
| 49 | +
|
| 50 | +const props = withDefaults(defineProps<{ |
| 51 | + length?: number, |
| 52 | + // a separator is drawn after every `groupSize` digits, 0 draws none |
| 53 | + groupSize?: number, |
| 54 | + invalid?: boolean, |
| 55 | + disabled?: boolean, |
| 56 | + autofocus?: boolean, |
| 57 | + // the code is the main control of the screen or dialog: after a click anywhere, e.g. on a copy button, |
| 58 | + // the focus comes back to it, Tab still moves on to other controls, a code pasted anywhere on the page fills it |
| 59 | + keepFocus?: boolean, |
| 60 | +}>(), { |
| 61 | + length: 6, |
| 62 | + groupSize: 0, |
| 63 | + invalid: false, |
| 64 | + disabled: false, |
| 65 | + autofocus: false, |
| 66 | + keepFocus: false, |
| 67 | +}); |
| 68 | +
|
| 69 | +const emit = defineEmits<{ |
| 70 | + (e: 'complete', code: string): void, |
| 71 | +}>(); |
| 72 | +
|
| 73 | +const code = defineModel<string>({ default: '' }); |
| 74 | +
|
| 75 | +const NON_DIGIT_RE = /\D/g; |
| 76 | +// controls Tab moves the focus to, keepFocus leaves it there |
| 77 | +const FOCUSABLE_CONTROL_SELECTOR = 'a[href], button, input, select, textarea, [contenteditable="true"], [tabindex]:not([tabindex="-1"])'; |
| 78 | +// another field takes the typing, keepFocus leaves the focus there after a click too |
| 79 | +const EDITABLE_SELECTOR = 'input, select, textarea, [contenteditable="true"]'; |
| 80 | +// the caret stays at the end, so keys which move it inside the code do nothing |
| 81 | +const CARET_KEYS = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End']; |
| 82 | +
|
| 83 | +const inputRef = ref<HTMLInputElement | null>(null); |
| 84 | +const isFocused = ref(false); |
| 85 | +// how the user moved the focus last time, Tab is left alone and a click brings the focus back |
| 86 | +let lastInteraction: 'pointer' | 'keyboard' = 'pointer'; |
| 87 | +
|
| 88 | +const attrs = useAttrs(); |
| 89 | +const restAttrs = computed(() => { |
| 90 | + const { class: _class, ...rest } = attrs; |
| 91 | + return rest; |
| 92 | +}); |
| 93 | +
|
| 94 | +const rootClasses = computed(() => mergeClasses( |
| 95 | + 'afcl-code-input relative inline-flex items-center gap-2', |
| 96 | + props.disabled && 'opacity-50', |
| 97 | + attrs.class as ClassNameValue, |
| 98 | +)); |
| 99 | +
|
| 100 | +const activeIndex = computed(() => isFocused.value ? Math.min(code.value.length, props.length - 1) : -1); |
| 101 | +
|
| 102 | +function cellClasses(index: number) { |
| 103 | + const isActive = index === activeIndex.value; |
| 104 | + return [ |
| 105 | + 'afcl-code-input-cell flex items-center justify-center w-11 h-12 shrink-0 rounded-default border', |
| 106 | + 'bg-lightInputBackground text-xl font-medium tabular-nums text-lightInputText shadow-customLight transition-[border-color,box-shadow]', |
| 107 | + 'dark:bg-darkInputBackground dark:text-darkInputText', |
| 108 | + props.invalid |
| 109 | + ? 'border-lightDanger dark:border-darkDanger' |
| 110 | + : 'border-lightInputBorder dark:border-darkInputBorder', |
| 111 | + isActive && (props.invalid |
| 112 | + ? 'ring-[3px] ring-lightDanger/20 dark:ring-darkDanger/30' |
| 113 | + : 'border-lightInputFocusBorder ring-[3px] ring-lightInputFocusRing dark:border-darkInputFocusBorder dark:ring-darkInputFocusRing'), |
| 114 | + ]; |
| 115 | +} |
| 116 | +
|
| 117 | +function isGroupEnd(position: number) { |
| 118 | + return props.groupSize > 0 && position % props.groupSize === 0 && position < props.length; |
| 119 | +} |
| 120 | +
|
| 121 | +function moveCaretToEnd() { |
| 122 | + const input = inputRef.value!; |
| 123 | + input.setSelectionRange(input.value.length, input.value.length); |
| 124 | +} |
| 125 | +
|
| 126 | +function setCode(raw: string) { |
| 127 | + const digits = raw.replace(NON_DIGIT_RE, '').slice(0, props.length); |
| 128 | + code.value = digits; |
| 129 | + // dropped letters do not change the model, so the field is synced by hand |
| 130 | + inputRef.value!.value = digits; |
| 131 | + moveCaretToEnd(); |
| 132 | + if (digits.length === props.length) { |
| 133 | + emit('complete', digits); |
| 134 | + } |
| 135 | +} |
| 136 | +
|
| 137 | +function focus() { |
| 138 | + inputRef.value?.focus(); |
| 139 | +} |
| 140 | +
|
| 141 | +function clear() { |
| 142 | + code.value = ''; |
| 143 | + inputRef.value!.value = ''; |
| 144 | + focus(); |
| 145 | +} |
| 146 | +
|
| 147 | +function onKeydown(e: KeyboardEvent) { |
| 148 | + if (CARET_KEYS.includes(e.key)) { |
| 149 | + e.preventDefault(); |
| 150 | + } |
| 151 | +} |
| 152 | +
|
| 153 | +// a pasted code may come with spaces or a dash ("123 456"), so it is cleaned before maxlength cuts it |
| 154 | +function onPaste(e: ClipboardEvent) { |
| 155 | + e.preventDefault(); |
| 156 | + setCode(e.clipboardData?.getData('text') ?? ''); |
| 157 | +} |
| 158 | +
|
| 159 | +function onFocus() { |
| 160 | + isFocused.value = true; |
| 161 | + moveCaretToEnd(); |
| 162 | +} |
| 163 | +
|
| 164 | +function onBlur(e: FocusEvent) { |
| 165 | + isFocused.value = false; |
| 166 | + const next = e.relatedTarget as Element | null; |
| 167 | + if (!props.keepFocus || next?.matches(EDITABLE_SELECTOR) || (lastInteraction === 'keyboard' && next?.matches(FOCUSABLE_CONTROL_SELECTOR))) { |
| 168 | + return; |
| 169 | + } |
| 170 | + // the clicked button still gets its click, the focus is back before the user types again |
| 171 | + requestAnimationFrame(() => { |
| 172 | + if (inputRef.value?.isConnected && !document.activeElement?.matches(EDITABLE_SELECTOR)) { |
| 173 | + focus(); |
| 174 | + } |
| 175 | + }); |
| 176 | +} |
| 177 | +
|
| 178 | +function markPointer() { |
| 179 | + lastInteraction = 'pointer'; |
| 180 | +} |
| 181 | +
|
| 182 | +function markKeyboard() { |
| 183 | + lastInteraction = 'keyboard'; |
| 184 | +} |
| 185 | +
|
| 186 | +function onWindowPaste(e: ClipboardEvent) { |
| 187 | + const target = e.target as Element; |
| 188 | + if (target === inputRef.value || target.closest(EDITABLE_SELECTOR)) { |
| 189 | + return; |
| 190 | + } |
| 191 | + const digits = (e.clipboardData?.getData('text') ?? '').replace(NON_DIGIT_RE, ''); |
| 192 | + if (digits.length !== props.length) { |
| 193 | + return; |
| 194 | + } |
| 195 | + e.preventDefault(); |
| 196 | + focus(); |
| 197 | + setCode(digits); |
| 198 | +} |
| 199 | +
|
| 200 | +onMounted(async () => { |
| 201 | + if (props.keepFocus) { |
| 202 | + window.addEventListener('paste', onWindowPaste); |
| 203 | + document.addEventListener('pointerdown', markPointer, true); |
| 204 | + document.addEventListener('keydown', markKeyboard, true); |
| 205 | + } |
| 206 | + if (props.autofocus) { |
| 207 | + // a dialog shows its content after this mount and focuses its panel on the next tick |
| 208 | + await nextTick(); |
| 209 | + requestAnimationFrame(focus); |
| 210 | + } |
| 211 | +}); |
| 212 | +
|
| 213 | +onBeforeUnmount(() => { |
| 214 | + window.removeEventListener('paste', onWindowPaste); |
| 215 | + document.removeEventListener('pointerdown', markPointer, true); |
| 216 | + document.removeEventListener('keydown', markKeyboard, true); |
| 217 | +}); |
| 218 | +
|
| 219 | +defineExpose({ focus, clear }); |
| 220 | +</script> |
0 commit comments