add OtpDialogModal

This commit is contained in:
pa
2026-02-12 20:49:55 +09:00
parent c93b3fbf9f
commit e643b6b5ad
14 changed files with 967 additions and 15 deletions

View File

@@ -0,0 +1,128 @@
<script setup>
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '@/components/ui/input-otp';
import { ref, watch } from 'vue';
import { Button } from '@/components/ui/button';
import { storeToRefs } from 'pinia';
import { useModalStore } from '@/stores';
const modalStore = useModalStore();
const { otpOpen, otpTitle, otpDescription, otpOkText, otpCancelText, otpDismissible, otpMode } =
storeToRefs(modalStore);
const otpValue = ref('');
function onComplete(value) {
modalStore.handleOtpOk(value);
}
function onSubmit() {
if (!otpValue.value) return;
modalStore.handleOtpOk(otpValue.value);
}
function onEscapeKeyDown(event) {
if (!otpDismissible.value) {
event.preventDefault();
return;
}
modalStore.handleOtpDismiss(otpValue.value);
}
function onPointerDownOutside(event) {
if (!otpDismissible.value) {
event.preventDefault();
return;
}
modalStore.handleOtpDismiss(otpValue.value);
}
function onInteractOutside(event) {
if (!otpDismissible.value) {
event.preventDefault();
return;
}
modalStore.handleOtpDismiss(otpValue.value);
}
function handleCancel() {
modalStore.handleOtpCancel(otpValue.value);
}
function stripNonAlphanumeric(value) {
return value.replace(/[^a-z0-9]/gi, '');
}
watch(otpOpen, (open) => {
if (open) {
otpValue.value = '';
}
});
</script>
<template>
<Dialog :open="otpOpen" @update:open="modalStore.setOtpOpen">
<DialogContent
:show-close-button="false"
@escapeKeyDown="onEscapeKeyDown"
@pointerDownOutside="onPointerDownOutside"
@interactOutside="onInteractOutside">
<form @submit.prevent="onSubmit">
<DialogHeader class="mb-5">
<DialogTitle>{{ otpTitle }}</DialogTitle>
<DialogDescription>{{ otpDescription }}</DialogDescription>
</DialogHeader>
<div class="flex justify-center">
<!-- TOTP / EmailOTP: 6 numeric digits -->
<InputOTP
v-if="otpMode === 'totp' || otpMode === 'emailOtp'"
v-model="otpValue"
:maxlength="6"
autofocus
inputmode="numeric"
@complete="onComplete">
<InputOTPGroup>
<InputOTPSlot v-for="i in 6" :key="i - 1" :index="i - 1" />
</InputOTPGroup>
</InputOTP>
<!-- OTP (recovery code): 4+4 alphanumeric with separator -->
<InputOTP
v-if="otpMode === 'otp'"
v-model="otpValue"
:maxlength="8"
autofocus
inputmode="text"
:paste-transformer="stripNonAlphanumeric"
@complete="onComplete">
<InputOTPGroup>
<InputOTPSlot v-for="i in 4" :key="i - 1" :index="i - 1" />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot v-for="i in 4" :key="i + 3" :index="i + 3" />
</InputOTPGroup>
</InputOTP>
</div>
<DialogFooter class="mt-5">
<Button type="button" variant="outline" @click="handleCancel">
{{ otpCancelText }}
</Button>
<Button type="submit">
{{ otpOkText }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View File

@@ -0,0 +1,78 @@
<script setup>
import { OTPInput } from 'vue-input-otp';
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
import { useForwardPropsEmits } from 'reka-ui';
const props = defineProps({
maxlength: { type: Number, required: true },
textAlign: { type: String, required: false },
inputmode: { type: String, required: false },
containerClass: { type: String, required: false },
pushPasswordManagerStrategy: { type: String, required: false },
noScriptCssFallback: { type: [String, null], required: false },
defaultValue: { type: null, required: false },
pasteTransformer: { type: Function, required: false },
accept: { type: String, required: false },
alt: { type: String, required: false },
autocomplete: { type: String, required: false },
autofocus: { type: Boolean, required: false },
capture: { type: [Boolean, String], required: false },
checked: { type: [Boolean, Array, Set], required: false },
crossorigin: { type: String, required: false },
disabled: { type: Boolean, required: false },
enterKeyHint: { type: String, required: false },
form: { type: String, required: false },
formaction: { type: String, required: false },
formenctype: { type: String, required: false },
formmethod: { type: String, required: false },
formnovalidate: { type: Boolean, required: false },
formtarget: { type: String, required: false },
height: { type: Number, required: false },
indeterminate: { type: Boolean, required: false },
list: { type: String, required: false },
max: { type: Number, required: false },
min: { type: Number, required: false },
minlength: { type: Number, required: false },
multiple: { type: Boolean, required: false },
name: { type: String, required: false },
pattern: { type: String, required: false },
placeholder: { type: String, required: false },
readonly: { type: Boolean, required: false },
required: { type: Boolean, required: false },
size: { type: Number, required: false },
src: { type: String, required: false },
step: { type: Number, required: false },
type: { type: String, required: false },
value: { type: null, required: false },
width: { type: Number, required: false },
class: { type: null, required: false }
});
const emits = defineEmits([
'complete',
'change',
'select',
'input',
'focus',
'blur',
'mouseover',
'mouseleave',
'paste'
]);
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<OTPInput
v-slot="slotProps"
v-bind="forwarded"
:container-class="cn('flex items-center gap-2 has-disabled:opacity-50', props.class)"
data-slot="input-otp"
class="disabled:cursor-not-allowed">
<slot v-bind="slotProps" />
</OTPInput>
</template>

View File

@@ -0,0 +1,19 @@
<script setup>
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
import { useForwardProps } from 'reka-ui';
const props = defineProps({
class: { type: null, required: false }
});
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<div data-slot="input-otp-group" v-bind="forwarded" :class="cn('flex items-center', props.class)">
<slot />
</div>
</template>

View File

@@ -0,0 +1,18 @@
<script setup>
import { MinusIcon } from 'lucide-vue-next';
import { useForwardProps } from 'reka-ui';
const props = defineProps({
class: { type: null, required: false }
});
const forwarded = useForwardProps(props);
</script>
<template>
<div data-slot="input-otp-separator" role="separator" v-bind="forwarded">
<slot>
<MinusIcon />
</slot>
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script setup>
import { cn } from '@/lib/utils';
import { computed } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { useForwardProps } from 'reka-ui';
import { useVueOTPContext } from 'vue-input-otp';
const props = defineProps({
index: { type: Number, required: true },
class: { type: null, required: false }
});
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardProps(delegatedProps);
const context = useVueOTPContext();
const slot = computed(() => context?.value.slots[props.index]);
</script>
<template>
<div
v-bind="forwarded"
data-slot="input-otp-slot"
:data-active="slot?.isActive"
:class="
cn(
'data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]',
props.class
)
">
{{ slot?.char }}
<div v-if="slot?.hasFakeCaret" class="pointer-events-none absolute inset-0 flex items-center justify-center">
<div class="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
</div>
</template>

View File

@@ -0,0 +1,4 @@
export { default as InputOTP } from './InputOTP.vue';
export { default as InputOTPGroup } from './InputOTPGroup.vue';
export { default as InputOTPSeparator } from './InputOTPSeparator.vue';
export { default as InputOTPSlot } from './InputOTPSlot.vue';