Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions packages/ui/src/components/base/TwoFactorAuthCodeInput.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
<template>
<div
class="flex w-fit flex-wrap gap-1.5"
:class="[wrapperClass, { 'opacity-50': disabled }]"
role="group"
aria-label="Two-factor authentication code"
@pointerdown="handlePointerDown"
>
<input
v-for="index in codeLength"
:key="index"
:ref="(element) => setCodeInput(element, index - 1)"
:value="digits[index - 1]"
type="text"
inputmode="numeric"
pattern="[0-9]*"
maxlength="1"
:autocomplete="index === 1 ? autocomplete : undefined"
:disabled="disabled"
:readonly="readonly"
:aria-label="`Code digit ${index}`"
class="h-12 w-11 appearance-none rounded-xl border-none bg-surface-4 p-1 text-center text-base font-medium text-primary focus:text-primary focus:ring-4 focus:ring-brand-shadow disabled:cursor-not-allowed"
:class="[inputClass, 'outline-none']"
@input="handleInput($event, index - 1)"
@keydown="handleKeydown($event, index - 1)"
@paste.prevent="handlePaste"
/>
</div>
</template>

<script setup lang="ts">
import { nextTick, ref, watch, type ComponentPublicInstance } from 'vue'

const model = defineModel<string>({ default: '' })

const props = withDefaults(
defineProps<{
autocomplete?: string
disabled?: boolean
readonly?: boolean
inputClass?: string
wrapperClass?: string
}>(),
{
autocomplete: 'one-time-code',
disabled: false,
readonly: false,
},
)

const codeInputs = ref<HTMLInputElement[]>([])
const digits = ref<string[]>([])
const codeLength = 6

watch(
() => model.value,
(value) => {
const sanitizedValue = sanitizeCode(value)
if (sanitizedValue !== digits.value.join('') || digits.value.length !== codeLength) {
digits.value = Array.from({ length: codeLength }, (_, index) => sanitizedValue[index] ?? '')
}

if (value !== sanitizedValue) {
model.value = sanitizedValue
}
},
{ immediate: true },
)

function sanitizeCode(value: string) {
return value.replace(/\D/g, '').slice(0, codeLength)
}

function updateModel() {
model.value = digits.value.join('')
}

function setCodeInput(element: Element | ComponentPublicInstance | null, index: number) {
if (element instanceof HTMLInputElement) {
codeInputs.value[index] = element
}
}

function focusInput(index: number) {
const input = codeInputs.value[index]
input?.focus()
input?.setSelectionRange(input.value.length, input.value.length)
}

function focusFirstUnfilledCodeInput() {
const firstUnfilledIndex = digits.value.findIndex((digit) => !digit)
const inputIndex = firstUnfilledIndex === -1 ? digits.value.length - 1 : firstUnfilledIndex
focusInput(inputIndex)
}

function handlePointerDown(event: PointerEvent) {
if (disabledOrReadonly()) {
return
}

event.preventDefault()
focusFirstUnfilledCodeInput()
}

function handleInput(event: Event, index: number) {
if (disabledOrReadonly()) {
return
}

const input = event.target as HTMLInputElement
const inputDigits = input.value.replace(/\D/g, '')

if (!inputDigits) {
digits.value[index] = ''
input.value = ''
updateModel()
return
}

if (inputDigits.length === 1) {
const digit = inputDigits.slice(-1)
digits.value[index] = digit
input.value = digit
updateModel()

if (index < codeLength - 1) {
focusInput(index + 1)
}
return
}

const pastedDigits = inputDigits.slice(0, codeLength - index)
for (const [offset, digit] of Array.from(pastedDigits).entries()) {
digits.value[index + offset] = digit
}
updateModel()
input.value = digits.value[index] ?? ''
void nextTick(focusFirstUnfilledCodeInput)
}

function handleKeydown(event: KeyboardEvent, index: number) {
if (disabledOrReadonly()) {
return
}

if (event.key === 'Backspace' && !digits.value[index] && index > 0) {
focusInput(index - 1)
} else if (event.key === 'ArrowLeft' && index > 0) {
event.preventDefault()
focusInput(index - 1)
} else if (event.key === 'ArrowRight' && index < codeLength - 1) {
event.preventDefault()
focusInput(index + 1)
}
}

function handlePaste(event: ClipboardEvent) {
if (disabledOrReadonly()) {
return
}

const clipboardText = event.clipboardData?.getData('text') ?? ''
const pastedCode = sanitizeCode(clipboardText)
if (!pastedCode) {
return
}

digits.value = Array.from({ length: codeLength }, (_, index) => pastedCode[index] ?? '')
updateModel()
void nextTick(focusFirstUnfilledCodeInput)
}

function disabledOrReadonly() {
return props.disabled || props.readonly
}

function clear() {
digits.value = Array.from({ length: codeLength }, () => '')
updateModel()
}

defineExpose({
clear,
focus: focusFirstUnfilledCodeInput,
})
</script>
1 change: 1 addition & 0 deletions packages/ui/src/components/base/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,5 @@ export type {
export { default as TimeFramePicker } from './TimeFramePicker.vue'
export { default as Timeline } from './Timeline.vue'
export { default as Toggle } from './Toggle.vue'
export { default as TwoFactorAuthCodeInput } from './TwoFactorAuthCodeInput.vue'
export { default as UnsavedChangesPopup } from './UnsavedChangesPopup.vue'
51 changes: 51 additions & 0 deletions packages/ui/src/stories/base/TwoFactorAuthCodeInput.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'

import TwoFactorAuthCodeInput from '../../components/base/TwoFactorAuthCodeInput.vue'

const meta = {
title: 'Base/TwoFactorAuthCodeInput',
component: TwoFactorAuthCodeInput,
} satisfies Meta<typeof TwoFactorAuthCodeInput>

export default meta
type Story = StoryObj<typeof meta>

export const Default: Story = {
render: () => ({
components: { TwoFactorAuthCodeInput },
setup() {
const code = ref('')
return { code }
},
template: `
<TwoFactorAuthCodeInput v-model="code" />
`,
}),
}

export const Filled: Story = {
render: () => ({
components: { TwoFactorAuthCodeInput },
setup() {
const code = ref('123456')
return { code }
},
template: `
<TwoFactorAuthCodeInput v-model="code" />
`,
}),
}

export const Disabled: Story = {
render: () => ({
components: { TwoFactorAuthCodeInput },
setup() {
const code = ref('123456')
return { code }
},
template: `
<TwoFactorAuthCodeInput v-model="code" disabled />
`,
}),
}
Loading