mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-01 04:33:46 +02:00
add OtpDialogModal
This commit is contained in:
348
src/components/__tests__/OtpDialogModal.test.js
Normal file
348
src/components/__tests__/OtpDialogModal.test.js
Normal file
@@ -0,0 +1,348 @@
|
||||
/* eslint-disable pretty-import/sort-import-groups */
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import en from '../../localization/en.json';
|
||||
|
||||
vi.mock('../../views/Feed/Feed.vue', () => ({
|
||||
default: { template: '<div />' }
|
||||
}));
|
||||
vi.mock('../../views/Feed/columns.jsx', () => ({ columns: [] }));
|
||||
vi.mock('../../plugin/router', () => ({
|
||||
router: {
|
||||
beforeEach: vi.fn(),
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
currentRoute: ref({ path: '/', name: '', meta: {} }),
|
||||
isReady: vi.fn().mockResolvedValue(true)
|
||||
},
|
||||
initRouter: vi.fn()
|
||||
}));
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
useRouter: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
currentRoute: ref({ path: '/', name: '', meta: {} })
|
||||
}))
|
||||
};
|
||||
});
|
||||
vi.mock('../../plugin/interopApi', () => ({ initInteropApi: vi.fn() }));
|
||||
vi.mock('../../service/database', () => ({
|
||||
database: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, prop) => {
|
||||
if (prop === '__esModule') return false;
|
||||
return vi.fn().mockResolvedValue(null);
|
||||
}
|
||||
}
|
||||
)
|
||||
}));
|
||||
vi.mock('../../service/config', () => ({
|
||||
default: {
|
||||
init: vi.fn(),
|
||||
getString: vi.fn().mockImplementation((_k, d) => d ?? '{}'),
|
||||
setString: vi.fn(),
|
||||
getBool: vi.fn().mockImplementation((_k, d) => d ?? false),
|
||||
setBool: vi.fn(),
|
||||
getInt: vi.fn().mockImplementation((_k, d) => d ?? 0),
|
||||
setInt: vi.fn(),
|
||||
getFloat: vi.fn().mockImplementation((_k, d) => d ?? 0),
|
||||
setFloat: vi.fn(),
|
||||
getObject: vi.fn().mockReturnValue(null),
|
||||
setObject: vi.fn(),
|
||||
getArray: vi.fn().mockReturnValue([]),
|
||||
setArray: vi.fn(),
|
||||
remove: vi.fn()
|
||||
}
|
||||
}));
|
||||
vi.mock('../../service/jsonStorage', () => ({ default: vi.fn() }));
|
||||
vi.mock('../../service/watchState', () => ({
|
||||
watchState: { isLoggedIn: false }
|
||||
}));
|
||||
|
||||
import OtpDialogModal from '../ui/dialog/OtpDialogModal.vue';
|
||||
import { useModalStore } from '../../stores/modal';
|
||||
|
||||
const i18n = createI18n({
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
legacy: false,
|
||||
globalInjection: false,
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
messages: { en }
|
||||
});
|
||||
|
||||
// Stubs: render children directly so we can inspect DOM without real reka-ui portals
|
||||
const stubs = {
|
||||
Dialog: {
|
||||
template: '<div class="dialog-stub" v-if="open"><slot /></div>',
|
||||
props: ['open']
|
||||
},
|
||||
DialogContent: {
|
||||
template: '<div class="dialog-content-stub"><slot /></div>'
|
||||
},
|
||||
DialogHeader: { template: '<div class="dialog-header"><slot /></div>' },
|
||||
DialogTitle: { template: '<span class="dialog-title"><slot /></span>' },
|
||||
DialogDescription: {
|
||||
template: '<span class="dialog-description"><slot /></span>'
|
||||
},
|
||||
DialogFooter: { template: '<div class="dialog-footer"><slot /></div>' },
|
||||
InputOTP: {
|
||||
name: 'InputOTP',
|
||||
template:
|
||||
'<div class="input-otp-stub"><input :data-maxlength="maxlength" :inputmode="inputmode" /><slot /></div>',
|
||||
props: ['maxlength', 'inputmode', 'modelValue', 'pasteTransformer'],
|
||||
emits: ['update:modelValue', 'complete']
|
||||
},
|
||||
InputOTPGroup: { template: '<div class="otp-group"><slot /></div>' },
|
||||
InputOTPSlot: {
|
||||
template: '<div class="otp-slot" :data-index="index"></div>',
|
||||
props: ['index']
|
||||
},
|
||||
InputOTPSeparator: { template: '<div class="otp-separator">-</div>' }
|
||||
};
|
||||
|
||||
function mountOtpDialog(storeOverrides = {}) {
|
||||
const pinia = createTestingPinia({
|
||||
stubActions: false,
|
||||
initialState: {
|
||||
Modal: storeOverrides
|
||||
}
|
||||
});
|
||||
return mount(OtpDialogModal, {
|
||||
global: {
|
||||
plugins: [i18n, pinia],
|
||||
stubs
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('OtpDialogModal.vue', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('visibility', () => {
|
||||
test('does not render when otpOpen is false', () => {
|
||||
const wrapper = mountOtpDialog({ otpOpen: false });
|
||||
store = useModalStore();
|
||||
expect(wrapper.find('.dialog-stub').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('renders dialog when otpOpen is true', () => {
|
||||
const wrapper = mountOtpDialog({ otpOpen: true });
|
||||
store = useModalStore();
|
||||
expect(wrapper.find('.dialog-stub').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('title and description', () => {
|
||||
test('displays title and description from store', () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpTitle: 'Enter TOTP Code',
|
||||
otpDescription: 'Check your authenticator app'
|
||||
});
|
||||
store = useModalStore();
|
||||
expect(wrapper.find('.dialog-title').text()).toBe(
|
||||
'Enter TOTP Code'
|
||||
);
|
||||
expect(wrapper.find('.dialog-description').text()).toBe(
|
||||
'Check your authenticator app'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mode rendering', () => {
|
||||
test('renders 6 slots for totp mode', () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpMode: 'totp'
|
||||
});
|
||||
store = useModalStore();
|
||||
const otpInput = wrapper.find('.input-otp-stub');
|
||||
expect(otpInput.exists()).toBe(true);
|
||||
expect(otpInput.find('input').attributes('data-maxlength')).toBe(
|
||||
'6'
|
||||
);
|
||||
expect(otpInput.find('input').attributes('inputmode')).toBe(
|
||||
'numeric'
|
||||
);
|
||||
const slots = wrapper.findAll('.otp-slot');
|
||||
expect(slots).toHaveLength(6);
|
||||
expect(wrapper.find('.otp-separator').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('renders 6 slots for emailOtp mode', () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpMode: 'emailOtp'
|
||||
});
|
||||
store = useModalStore();
|
||||
const otpInput = wrapper.find('.input-otp-stub');
|
||||
expect(otpInput.exists()).toBe(true);
|
||||
expect(otpInput.find('input').attributes('data-maxlength')).toBe(
|
||||
'6'
|
||||
);
|
||||
expect(otpInput.find('input').attributes('inputmode')).toBe(
|
||||
'numeric'
|
||||
);
|
||||
const slots = wrapper.findAll('.otp-slot');
|
||||
expect(slots).toHaveLength(6);
|
||||
});
|
||||
|
||||
test('renders 8 slots with separator for otp (recovery) mode', () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpMode: 'otp'
|
||||
});
|
||||
store = useModalStore();
|
||||
const otpInput = wrapper.find('.input-otp-stub');
|
||||
expect(otpInput.exists()).toBe(true);
|
||||
expect(otpInput.find('input').attributes('data-maxlength')).toBe(
|
||||
'8'
|
||||
);
|
||||
expect(otpInput.find('input').attributes('inputmode')).toBe('text');
|
||||
const slots = wrapper.findAll('.otp-slot');
|
||||
expect(slots).toHaveLength(8);
|
||||
expect(wrapper.find('.otp-separator').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('does not render otp input when mode is totp', () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpMode: 'totp'
|
||||
});
|
||||
store = useModalStore();
|
||||
// Should not have the 8-slot recovery code input
|
||||
const inputs = wrapper.findAll('.input-otp-stub');
|
||||
expect(inputs).toHaveLength(1);
|
||||
expect(inputs[0].find('input').attributes('data-maxlength')).toBe(
|
||||
'6'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('button text', () => {
|
||||
test('displays ok and cancel text from store', () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpOkText: 'Verify',
|
||||
otpCancelText: 'Use Recovery'
|
||||
});
|
||||
store = useModalStore();
|
||||
const buttons = wrapper.findAll('button');
|
||||
const cancelBtn = buttons.find((b) => b.text() === 'Use Recovery');
|
||||
const okBtn = buttons.find((b) => b.text() === 'Verify');
|
||||
expect(cancelBtn).toBeTruthy();
|
||||
expect(okBtn).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel button', () => {
|
||||
test('calls handleOtpCancel when cancel button is clicked', async () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpOkText: 'Verify',
|
||||
otpCancelText: 'Cancel'
|
||||
});
|
||||
store = useModalStore();
|
||||
const spy = vi.spyOn(store, 'handleOtpCancel');
|
||||
|
||||
const cancelBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text() === 'Cancel');
|
||||
await cancelBtn.trigger('click');
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('submit', () => {
|
||||
test('does not call handleOtpOk on form submit when value is empty', async () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true
|
||||
});
|
||||
store = useModalStore();
|
||||
const spy = vi.spyOn(store, 'handleOtpOk');
|
||||
|
||||
await wrapper.find('form').trigger('submit');
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('complete event', () => {
|
||||
test('calls handleOtpOk when InputOTP emits complete', async () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpMode: 'totp'
|
||||
});
|
||||
store = useModalStore();
|
||||
const spy = vi.spyOn(store, 'handleOtpOk');
|
||||
|
||||
const otpInput = wrapper.findComponent({ name: 'InputOTP' });
|
||||
otpInput.vm.$emit('complete', '123456');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('123456');
|
||||
});
|
||||
});
|
||||
|
||||
describe('value reset', () => {
|
||||
test('resets otpValue when dialog opens', async () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: false,
|
||||
otpMode: 'totp'
|
||||
});
|
||||
store = useModalStore();
|
||||
|
||||
// Simulate opening: the watcher clears the value
|
||||
store.otpOpen = true;
|
||||
await wrapper.vm.$nextTick();
|
||||
// Wait for the watcher + nextTick inside it
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// Dialog should now be visible and InputOTP should have empty modelValue
|
||||
const otpInput = wrapper.findComponent({ name: 'InputOTP' });
|
||||
expect(otpInput.exists()).toBe(true);
|
||||
expect(otpInput.props('modelValue')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('paste transformer', () => {
|
||||
test('recovery code InputOTP has pasteTransformer that strips non-alphanumeric chars', () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpMode: 'otp'
|
||||
});
|
||||
store = useModalStore();
|
||||
const otpInput = wrapper.findComponent({ name: 'InputOTP' });
|
||||
const transformer = otpInput.props('pasteTransformer');
|
||||
expect(transformer).toBeTypeOf('function');
|
||||
expect(transformer('abcd-1234')).toBe('abcd1234');
|
||||
expect(transformer('ab-cd-12-34')).toBe('abcd1234');
|
||||
expect(transformer('abcd1234')).toBe('abcd1234');
|
||||
});
|
||||
|
||||
test('totp InputOTP does not have pasteTransformer', () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpMode: 'totp'
|
||||
});
|
||||
store = useModalStore();
|
||||
const otpInput = wrapper.findComponent({ name: 'InputOTP' });
|
||||
expect(otpInput.props('pasteTransformer')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
128
src/components/ui/dialog/OtpDialogModal.vue
Normal file
128
src/components/ui/dialog/OtpDialogModal.vue
Normal 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>
|
||||
78
src/components/ui/input-otp/InputOTP.vue
Normal file
78
src/components/ui/input-otp/InputOTP.vue
Normal 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>
|
||||
19
src/components/ui/input-otp/InputOTPGroup.vue
Normal file
19
src/components/ui/input-otp/InputOTPGroup.vue
Normal 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>
|
||||
18
src/components/ui/input-otp/InputOTPSeparator.vue
Normal file
18
src/components/ui/input-otp/InputOTPSeparator.vue
Normal 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>
|
||||
38
src/components/ui/input-otp/InputOTPSlot.vue
Normal file
38
src/components/ui/input-otp/InputOTPSlot.vue
Normal 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>
|
||||
4
src/components/ui/input-otp/index.js
Normal file
4
src/components/ui/input-otp/index.js
Normal 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';
|
||||
Reference in New Issue
Block a user