├── eslint.config.js ├── website ├── server │ └── tsconfig.json ├── app │ ├── components │ │ ├── ui │ │ │ ├── sonner │ │ │ │ ├── index.ts │ │ │ │ └── Sonner.vue │ │ │ ├── dropdown-menu │ │ │ │ ├── DropdownMenuGroup.vue │ │ │ │ ├── DropdownMenuShortcut.vue │ │ │ │ ├── DropdownMenuTrigger.vue │ │ │ │ ├── DropdownMenuSub.vue │ │ │ │ ├── DropdownMenu.vue │ │ │ │ ├── DropdownMenuRadioGroup.vue │ │ │ │ ├── DropdownMenuSeparator.vue │ │ │ │ ├── DropdownMenuLabel.vue │ │ │ │ ├── index.ts │ │ │ │ ├── DropdownMenuSubTrigger.vue │ │ │ │ ├── DropdownMenuSubContent.vue │ │ │ │ ├── DropdownMenuRadioItem.vue │ │ │ │ ├── DropdownMenuCheckboxItem.vue │ │ │ │ ├── DropdownMenuItem.vue │ │ │ │ └── DropdownMenuContent.vue │ │ │ └── button │ │ │ │ ├── Button.vue │ │ │ │ └── index.ts │ │ ├── PageActions.vue │ │ ├── PageHeaderDescription.vue │ │ ├── PageHeader.vue │ │ ├── PageHeaderHeading.vue │ │ ├── SiteFooter.vue │ │ ├── CopyButton.vue │ │ ├── Slot.vue │ │ ├── SiteHeader.vue │ │ ├── Showcase.vue │ │ └── Icons.ts │ ├── lib │ │ └── utils.ts │ ├── app.vue │ ├── pages │ │ └── index.vue │ └── assets │ │ └── css │ │ └── tailwind.css ├── public │ └── favicon.ico ├── .gitignore ├── tsconfig.json ├── components.json ├── nuxt.config.ts ├── package.json └── README.md ├── pnpm-workspace.yaml ├── .gitignore ├── src ├── regexp.ts ├── use-otp-context.ts ├── symbols.ts ├── sync-timeouts.ts ├── index.ts ├── NoSciptCssFallback.ts ├── types.ts ├── use-pwm-badge.ts └── OTPInput.vue ├── tsconfig.json ├── tsconfig.node.json ├── tsdown.config.ts ├── .changeset ├── config.json └── README.md ├── CHANGELOG.md ├── LICENSE ├── .github └── workflows │ └── release.yml ├── package.json └── README.md /eslint.config.js: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu() 4 | -------------------------------------------------------------------------------- /website/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /website/app/components/ui/sonner/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Toaster } from './Sonner.vue' 2 | -------------------------------------------------------------------------------- /website/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wobsoriano/vue-input-otp/HEAD/website/public/favicon.ico -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - . 3 | - website 4 | 5 | onlyBuiltDependencies: 6 | - esbuild 7 | - vue-demi 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .DS_Store 3 | .idea 4 | *.log 5 | *.tgz 6 | coverage 7 | dist 8 | lib-cov 9 | logs 10 | node_modules 11 | temp 12 | .vscode 13 | -------------------------------------------------------------------------------- /src/regexp.ts: -------------------------------------------------------------------------------- 1 | export const REGEXP_ONLY_DIGITS = '^\\d+$' 2 | export const REGEXP_ONLY_CHARS = '^[a-zA-Z]+$' 3 | export const REGEXP_ONLY_DIGITS_AND_CHARS = '^[a-zA-Z0-9]+$' 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.dom.json", 3 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], 4 | "exclude": ["website"] 5 | } 6 | -------------------------------------------------------------------------------- /src/use-otp-context.ts: -------------------------------------------------------------------------------- 1 | import { inject } from 'vue' 2 | import { PublicVueOTPContextKey } from './symbols' 3 | 4 | export function useVueOTPContext() { 5 | return inject(PublicVueOTPContextKey) 6 | } 7 | -------------------------------------------------------------------------------- /src/symbols.ts: -------------------------------------------------------------------------------- 1 | import type { ComputedRef, InjectionKey } from 'vue' 2 | import type { RenderProps } from './types' 3 | 4 | export const PublicVueOTPContextKey: InjectionKey> = Symbol('vue-otp-context') 5 | -------------------------------------------------------------------------------- /website/app/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import type { ClassValue } from 'clsx' 2 | import { clsx } from 'clsx' 3 | import { twMerge } from 'tailwind-merge' 4 | 5 | export function cn(...inputs: ClassValue[]) { 6 | return twMerge(clsx(inputs)) 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["tsdown.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /src/sync-timeouts.ts: -------------------------------------------------------------------------------- 1 | export function syncTimeouts(cb: (...args: any[]) => unknown): number[] { 2 | const t1 = setTimeout(cb, 0) // For faster machines 3 | const t2 = setTimeout(cb, 1_0) 4 | const t3 = setTimeout(cb, 5_0) 5 | return [t1, t2, t3] 6 | } 7 | -------------------------------------------------------------------------------- /tsdown.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsdown' 2 | import Vue from 'unplugin-vue/rolldown' 3 | 4 | export default defineConfig({ 5 | entry: ['./src/index.ts'], 6 | platform: 'neutral', 7 | plugins: [Vue({ isProduction: true })], 8 | external: ['vue'], 9 | dts: { vue: true }, 10 | }) 11 | -------------------------------------------------------------------------------- /website/.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | # Node dependencies 10 | node_modules 11 | 12 | # Logs 13 | logs 14 | *.log 15 | 16 | # Misc 17 | .DS_Store 18 | .fleet 19 | .idea 20 | 21 | # Local env files 22 | .env 23 | .env.* 24 | !.env.example 25 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | default as OTPInput, 3 | } from './OTPInput.vue' 4 | 5 | export * from './regexp' 6 | 7 | export { PublicVueOTPContextKey } from './symbols' 8 | 9 | export type { OTPInputEmits, OTPInputProps, RenderProps, SlotProps } from './types' 10 | export { useVueOTPContext } from './use-otp-context' 11 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": ["website"] 11 | } 12 | -------------------------------------------------------------------------------- /website/app/components/PageActions.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /website/app/components/PageHeaderDescription.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /website/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "references": [ 3 | { 4 | "path": "./.nuxt/tsconfig.app.json" 5 | }, 6 | { 7 | "path": "./.nuxt/tsconfig.server.json" 8 | }, 9 | { 10 | "path": "./.nuxt/tsconfig.shared.json" 11 | }, 12 | { 13 | "path": "./.nuxt/tsconfig.node.json" 14 | } 15 | ], 16 | "files": [] 17 | } 18 | -------------------------------------------------------------------------------- /website/app/components/PageHeader.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /website/app/components/PageHeaderHeading.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /website/app/components/ui/dropdown-menu/DropdownMenuGroup.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 16 | -------------------------------------------------------------------------------- /website/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /website/app/app.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 22 | -------------------------------------------------------------------------------- /website/app/components/ui/dropdown-menu/DropdownMenuTrigger.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | -------------------------------------------------------------------------------- /website/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://shadcn-vue.com/schema.json", 3 | "style": "new-york", 4 | "typescript": true, 5 | "tailwind": { 6 | "config": "", 7 | "css": "app/assets/css/tailwind.css", 8 | "baseColor": "neutral", 9 | "cssVariables": true, 10 | "prefix": "" 11 | }, 12 | "iconLibrary": "lucide", 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "composables": "@/composables" 19 | }, 20 | "registries": {} 21 | } 22 | -------------------------------------------------------------------------------- /website/app/components/ui/dropdown-menu/DropdownMenuSub.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 19 | -------------------------------------------------------------------------------- /website/app/components/ui/dropdown-menu/DropdownMenu.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 19 | -------------------------------------------------------------------------------- /website/app/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 22 | -------------------------------------------------------------------------------- /website/app/components/ui/dropdown-menu/DropdownMenuSeparator.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 24 | -------------------------------------------------------------------------------- /website/app/components/ui/button/Button.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 30 | -------------------------------------------------------------------------------- /website/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import tailwind from '@tailwindcss/vite' 2 | 3 | export default defineNuxtConfig({ 4 | modules: ['shadcn-nuxt'], 5 | css: [ 6 | '~/assets/css/tailwind.css', 7 | 'vue-sonner/style.css', 8 | ], 9 | shadcn: { 10 | /** 11 | * Prefix for all the imported component 12 | */ 13 | prefix: '', 14 | /** 15 | * Directory that the component lives in. 16 | * @default "@/components/ui" 17 | */ 18 | componentDir: '@/components/ui', 19 | }, 20 | vite: { 21 | plugins: [ 22 | tailwind(), 23 | ], 24 | }, 25 | runtimeConfig: { 26 | public: { 27 | gitHubUrl: 'https://github.com/wobsoriano', 28 | repoUrl: 'https://github.com/wobsoriano/vue-input-otp', 29 | twitterUrl: 'https://twitter.com/wobsoriano', 30 | }, 31 | }, 32 | 33 | compatibilityDate: '2024-08-25', 34 | }) 35 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # vue-input-otp 2 | 3 | ## 0.3.2 4 | 5 | ### Patch Changes 6 | 7 | - 45e8ae1: Switch to OIDC for npm publishing 8 | 9 | ## 0.3.1 10 | 11 | ### Patch Changes 12 | 13 | - 45e3d44: Removed the default pattern. 14 | 15 | ## 0.3.0 16 | 17 | ### Minor Changes 18 | 19 | - a1f17dc: Introduce `useVueOTPContext` to allow access to slots, focused and hovering states throughout the component tree. 20 | 21 | ## 0.2.2 22 | 23 | ### Patch Changes 24 | 25 | - 600016f: Add package keywords 26 | 27 | ## 0.2.1 28 | 29 | ### Patch Changes 30 | 31 | - 7d6ee46: Fix password manager positioning 32 | 33 | ## 0.2.0 34 | 35 | ### Minor Changes 36 | 37 | - b2af761: Password manager support and more improvements 38 | - 6a4ca68: Emit the value instead of event from input event 39 | 40 | ### Patch Changes 41 | 42 | - 9ef505c: Remove console logs 43 | - c3c427c: Fix paste listener 44 | -------------------------------------------------------------------------------- /website/app/components/ui/dropdown-menu/DropdownMenuLabel.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 24 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "website", 3 | "type": "module", 4 | "version": "0.0.0", 5 | "private": true, 6 | "scripts": { 7 | "build": "nuxt build", 8 | "dev": "nuxt dev", 9 | "generate": "nuxt generate", 10 | "preview": "nuxt preview", 11 | "postinstall": "nuxt prepare" 12 | }, 13 | "dependencies": { 14 | "@radix-icons/vue": "^1.0.0", 15 | "@vueuse/core": "^14.1.0", 16 | "balloons-js": "^0.0.3", 17 | "class-variance-authority": "^0.7.1", 18 | "clsx": "^2.1.1", 19 | "lucide-vue-next": "^0.548.0", 20 | "reka-ui": "^2.6.1", 21 | "tailwind-merge": "^3.4.0", 22 | "tw-animate-css": "^1.4.0", 23 | "vue": "^3.5.25", 24 | "vue-sonner": "^2.0.9" 25 | }, 26 | "devDependencies": { 27 | "@tailwindcss/vite": "^4.1.17", 28 | "nuxt": "^4.2.1", 29 | "shadcn-nuxt": "^2.4.0", 30 | "tailwindcss": "^4.1.17", 31 | "vue-input-otp": "workspace:*" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/NoSciptCssFallback.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, h } from 'vue' 2 | 3 | export const NOSCRIPT_CSS_FALLBACK = ` 4 | [data-input-otp] { 5 | --nojs-bg: white !important; 6 | --nojs-fg: black !important; 7 | 8 | background-color: var(--nojs-bg) !important; 9 | color: var(--nojs-fg) !important; 10 | caret-color: var(--nojs-fg) !important; 11 | letter-spacing: .25em !important; 12 | text-align: center !important; 13 | border: 1px solid var(--nojs-fg) !important; 14 | border-radius: 4px !important; 15 | width: 100% !important; 16 | } 17 | @media (prefers-color-scheme: dark) { 18 | [data-input-otp] { 19 | --nojs-bg: black !important; 20 | --nojs-fg: white !important; 21 | } 22 | }` 23 | 24 | export const NoSciptCssFallback = defineComponent({ 25 | props: { 26 | fallback: { 27 | type: String, 28 | required: true, 29 | }, 30 | }, 31 | setup(props) { 32 | return () => h('noscript', { innerHTML: `` }) 33 | }, 34 | }) 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Robert Soriano 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /website/app/components/ui/dropdown-menu/index.ts: -------------------------------------------------------------------------------- 1 | export { default as DropdownMenu } from './DropdownMenu.vue' 2 | 3 | export { default as DropdownMenuCheckboxItem } from './DropdownMenuCheckboxItem.vue' 4 | export { default as DropdownMenuContent } from './DropdownMenuContent.vue' 5 | export { default as DropdownMenuGroup } from './DropdownMenuGroup.vue' 6 | export { default as DropdownMenuItem } from './DropdownMenuItem.vue' 7 | export { default as DropdownMenuLabel } from './DropdownMenuLabel.vue' 8 | export { default as DropdownMenuRadioGroup } from './DropdownMenuRadioGroup.vue' 9 | export { default as DropdownMenuRadioItem } from './DropdownMenuRadioItem.vue' 10 | export { default as DropdownMenuSeparator } from './DropdownMenuSeparator.vue' 11 | export { default as DropdownMenuShortcut } from './DropdownMenuShortcut.vue' 12 | export { default as DropdownMenuSub } from './DropdownMenuSub.vue' 13 | export { default as DropdownMenuSubContent } from './DropdownMenuSubContent.vue' 14 | export { default as DropdownMenuSubTrigger } from './DropdownMenuSubTrigger.vue' 15 | export { default as DropdownMenuTrigger } from './DropdownMenuTrigger.vue' 16 | export { DropdownMenuPortal } from 'reka-ui' 17 | -------------------------------------------------------------------------------- /website/README.md: -------------------------------------------------------------------------------- 1 | # Nuxt 3 Minimal Starter 2 | 3 | Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. 4 | 5 | ## Setup 6 | 7 | Make sure to install the dependencies: 8 | 9 | ```bash 10 | # npm 11 | npm install 12 | 13 | # pnpm 14 | pnpm install 15 | 16 | # yarn 17 | yarn install 18 | 19 | # bun 20 | bun install 21 | ``` 22 | 23 | ## Development Server 24 | 25 | Start the development server on `http://localhost:3000`: 26 | 27 | ```bash 28 | # npm 29 | npm run dev 30 | 31 | # pnpm 32 | pnpm run dev 33 | 34 | # yarn 35 | yarn dev 36 | 37 | # bun 38 | bun run dev 39 | ``` 40 | 41 | ## Production 42 | 43 | Build the application for production: 44 | 45 | ```bash 46 | # npm 47 | npm run build 48 | 49 | # pnpm 50 | pnpm run build 51 | 52 | # yarn 53 | yarn build 54 | 55 | # bun 56 | bun run build 57 | ``` 58 | 59 | Locally preview production build: 60 | 61 | ```bash 62 | # npm 63 | npm run preview 64 | 65 | # pnpm 66 | pnpm run preview 67 | 68 | # yarn 69 | yarn preview 70 | 71 | # bun 72 | bun run preview 73 | ``` 74 | 75 | Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. 76 | -------------------------------------------------------------------------------- /website/app/components/SiteFooter.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 35 | -------------------------------------------------------------------------------- /website/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | permissions: 13 | contents: write # to create release (changesets/action) 14 | pull-requests: write # to create pull request (changesets/action) 15 | id-token: write # allows GitHub Actions to generate OIDC tokens 16 | name: Release 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout Repo 20 | uses: actions/checkout@v3 21 | with: 22 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits 23 | fetch-depth: 0 24 | - uses: pnpm/action-setup@v4 25 | with: 26 | version: 10.24.0 27 | - name: Setup Node.js 28 | uses: actions/setup-node@v3 29 | with: 30 | node-version: '24' 31 | cache: pnpm 32 | registry-url: https://registry.npmjs.org 33 | 34 | - run: pnpm install 35 | 36 | - name: Create Release Pull Request or Publish to npm 37 | id: changesets 38 | uses: changesets/action@v1 39 | with: 40 | publish: pnpm release 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | -------------------------------------------------------------------------------- /website/app/components/ui/sonner/Sonner.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 43 | -------------------------------------------------------------------------------- /website/app/components/ui/dropdown-menu/DropdownMenuSubContent.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 28 | -------------------------------------------------------------------------------- /website/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 39 | -------------------------------------------------------------------------------- /website/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 38 | -------------------------------------------------------------------------------- /website/app/components/ui/dropdown-menu/DropdownMenuItem.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 32 | -------------------------------------------------------------------------------- /website/app/components/ui/dropdown-menu/DropdownMenuContent.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 36 | -------------------------------------------------------------------------------- /website/app/components/CopyButton.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 56 | -------------------------------------------------------------------------------- /website/app/components/Slot.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-input-otp", 3 | "type": "module", 4 | "version": "0.3.2", 5 | "packageManager": "pnpm@10.24.0", 6 | "description": "", 7 | "author": "Robert Soriano ", 8 | "license": "MIT", 9 | "homepage": "https://github.com/wobsoriano/vue-input-otp#readme", 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/wobsoriano/vue-input-otp.git" 13 | }, 14 | "bugs": "https://github.com/wobsoriano/vue-input-otp/issues", 15 | "keywords": [ 16 | "vue", 17 | "otp", 18 | "input", 19 | "accessible" 20 | ], 21 | "sideEffects": false, 22 | "exports": { 23 | ".": "./dist/index.js", 24 | "./package.json": "./package.json" 25 | }, 26 | "main": "./dist/index.js", 27 | "module": "./dist/index.js", 28 | "types": "./dist/index.d.ts", 29 | "files": [ 30 | "dist" 31 | ], 32 | "scripts": { 33 | "build": "tsdown", 34 | "dev": "tsdown --watch", 35 | "website": "pnpm --filter website dev", 36 | "dev:website": "pnpm dev & pnpm website", 37 | "prepare": "tsdown", 38 | "lint": "eslint .", 39 | "lint:fix": "eslint . --fix", 40 | "release": "pnpm build && changeset publish", 41 | "prepublishOnly": "pnpm build", 42 | "taze": "taze" 43 | }, 44 | "peerDependencies": { 45 | "vue": "^3.2.0" 46 | }, 47 | "dependencies": { 48 | "@vueuse/core": "^12.8.2", 49 | "reka-ui": "^2.6.1" 50 | }, 51 | "devDependencies": { 52 | "@antfu/eslint-config": "^6.2.0", 53 | "@changesets/cli": "^2.29.8", 54 | "@vue/tsconfig": "^0.8.1", 55 | "eslint": "^9.39.1", 56 | "reka-ui": "^2.6.1", 57 | "taze": "^19.9.2", 58 | "tsdown": "^0.15.12", 59 | "typescript": "^5.9.3", 60 | "unplugin-vue": "^7.0.8", 61 | "vue": "^3.5.25", 62 | "vue-tsc": "^3.1.5" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /website/app/components/SiteHeader.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 58 | -------------------------------------------------------------------------------- /website/app/components/ui/button/index.ts: -------------------------------------------------------------------------------- 1 | import type { VariantProps } from 'class-variance-authority' 2 | import { cva } from 'class-variance-authority' 3 | 4 | export { default as Button } from './Button.vue' 5 | 6 | export const buttonVariants = cva( 7 | 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | 'bg-primary text-primary-foreground hover:bg-primary/90', 13 | destructive: 14 | 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', 15 | outline: 16 | 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', 17 | secondary: 18 | 'bg-secondary text-secondary-foreground hover:bg-secondary/80', 19 | ghost: 20 | 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', 21 | link: 'text-primary underline-offset-4 hover:underline', 22 | }, 23 | size: { 24 | 'default': 'h-9 px-4 py-2 has-[>svg]:px-3', 25 | 'sm': 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', 26 | 'lg': 'h-10 rounded-md px-6 has-[>svg]:px-4', 27 | 'icon': 'size-9', 28 | 'icon-sm': 'size-8', 29 | 'icon-lg': 'size-10', 30 | }, 31 | }, 32 | defaultVariants: { 33 | variant: 'default', 34 | size: 'default', 35 | }, 36 | }, 37 | ) 38 | export type ButtonVariants = VariantProps 39 | -------------------------------------------------------------------------------- /website/app/pages/index.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 53 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | // Need to figure out how to import this from Vue without causing "Failed to resolve extends base type" error 2 | export interface InputHTMLAttributes { 3 | accept?: string 4 | alt?: string 5 | autocomplete?: string 6 | autofocus?: boolean 7 | capture?: boolean | 'user' | 'environment' 8 | checked?: boolean | any[] | Set 9 | crossorigin?: string 10 | disabled?: boolean 11 | enterKeyHint?: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send' 12 | form?: string 13 | formaction?: string 14 | formenctype?: string 15 | formmethod?: string 16 | formnovalidate?: boolean 17 | formtarget?: string 18 | height?: number 19 | indeterminate?: boolean 20 | list?: string 21 | max?: number 22 | maxlength?: number 23 | min?: number 24 | minlength?: number 25 | multiple?: boolean 26 | name?: string 27 | pattern?: string 28 | placeholder?: string 29 | readonly?: boolean 30 | required?: boolean 31 | size?: number 32 | src?: string 33 | step?: number 34 | type?: string 35 | value?: any 36 | width?: number 37 | } 38 | 39 | export interface OTPInputProps extends InputHTMLAttributes { 40 | maxlength: number 41 | textAlign?: 'left' | 'center' | 'right' 42 | inputmode?: 'numeric' | 'text' 43 | containerClass?: string 44 | pushPasswordManagerStrategy?: 'increase-width' | 'none' 45 | noScriptCssFallback?: string | null 46 | defaultValue?: any 47 | pasteTransformer?: (pasted: string | undefined) => string 48 | } 49 | 50 | export interface OTPInputEmits { 51 | (event: 'complete', value: string): void 52 | (event: 'change', e: Event): void 53 | (event: 'select', e: Event): void 54 | (event: 'input', value: string): void 55 | (event: 'focus', e: FocusEvent): void 56 | (event: 'blur', e: FocusEvent): void 57 | (event: 'mouseover', e: MouseEvent): void 58 | (event: 'mouseleave', e: MouseEvent): void 59 | (event: 'paste', e: ClipboardEvent): void 60 | } 61 | 62 | export interface SlotProps { 63 | isActive: boolean 64 | char: string | null 65 | placeholderChar: string | null 66 | hasFakeCaret: boolean 67 | } 68 | 69 | export interface RenderProps { 70 | slots: SlotProps[] 71 | isFocused: boolean 72 | isHovering: boolean 73 | } 74 | -------------------------------------------------------------------------------- /website/app/components/Showcase.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 100 | -------------------------------------------------------------------------------- /website/app/components/Icons.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, h } from 'vue' 2 | 3 | export const GitHub = defineComponent(() => { 4 | return () => h('svg', { 5 | viewBox: '0 0 438.549 438.549', 6 | }, h('path', { 7 | fill: 'currentColor', 8 | d: 'M409.132 114.573c-19.608-33.596-46.205-60.194-79.798-79.8-33.598-19.607-70.277-29.408-110.063-29.408-39.781 0-76.472 9.804-110.063 29.408-33.596 19.605-60.192 46.204-79.8 79.8C9.803 148.168 0 184.854 0 224.63c0 47.78 13.94 90.745 41.827 128.906 27.884 38.164 63.906 64.572 108.063 79.227 5.14.954 8.945.283 11.419-1.996 2.475-2.282 3.711-5.14 3.711-8.562 0-.571-.049-5.708-.144-15.417a2549.81 2549.81 0 01-.144-25.406l-6.567 1.136c-4.187.767-9.469 1.092-15.846 1-6.374-.089-12.991-.757-19.842-1.999-6.854-1.231-13.229-4.086-19.13-8.559-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-.951-2.568-2.098-3.711-3.429-1.142-1.331-1.997-2.663-2.568-3.997-.572-1.335-.098-2.43 1.427-3.289 1.525-.859 4.281-1.276 8.28-1.276l5.708.853c3.807.763 8.516 3.042 14.133 6.851 5.614 3.806 10.229 8.754 13.846 14.842 4.38 7.806 9.657 13.754 15.846 17.847 6.184 4.093 12.419 6.136 18.699 6.136 6.28 0 11.704-.476 16.274-1.423 4.565-.952 8.848-2.383 12.847-4.285 1.713-12.758 6.377-22.559 13.988-29.41-10.848-1.14-20.601-2.857-29.264-5.14-8.658-2.286-17.605-5.996-26.835-11.14-9.235-5.137-16.896-11.516-22.985-19.126-6.09-7.614-11.088-17.61-14.987-29.979-3.901-12.374-5.852-26.648-5.852-42.826 0-23.035 7.52-42.637 22.557-58.817-7.044-17.318-6.379-36.732 1.997-58.24 5.52-1.715 13.706-.428 24.554 3.853 10.85 4.283 18.794 7.952 23.84 10.994 5.046 3.041 9.089 5.618 12.135 7.708 17.705-4.947 35.976-7.421 54.818-7.421s37.117 2.474 54.823 7.421l10.849-6.849c7.419-4.57 16.18-8.758 26.262-12.565 10.088-3.805 17.802-4.853 23.134-3.138 8.562 21.509 9.325 40.922 2.279 58.24 15.036 16.18 22.559 35.787 22.559 58.817 0 16.178-1.958 30.497-5.853 42.966-3.9 12.471-8.941 22.457-15.125 29.979-6.191 7.521-13.901 13.85-23.131 18.986-9.232 5.14-18.182 8.85-26.84 11.136-8.662 2.286-18.415 4.004-29.263 5.146 9.894 8.562 14.842 22.077 14.842 40.539v60.237c0 3.422 1.19 6.279 3.572 8.562 2.379 2.279 6.136 2.95 11.276 1.995 44.163-14.653 80.185-41.062 108.068-79.226 27.88-38.161 41.825-81.126 41.825-128.906-.01-39.771-9.818-76.454-29.414-110.049z', 9 | })) 10 | }) 11 | 12 | export const Twitter = defineComponent(() => { 13 | return () => h('svg', { 14 | height: 23, 15 | width: 23, 16 | viewBox: '0 0 1200 1227', 17 | xmlns: 'http://www.w3.org/2000/svg', 18 | }, h('path', { 19 | d: 'M714.163 519.284L1160.89 0H1055.03L667.137 450.887L357.328 0H0L468.492 681.821L0 1226.37H105.866L515.491 750.218L842.672 1226.37H1200L714.137 519.284H714.163ZM569.165 687.828L521.697 619.934L144.011 79.6944H306.615L611.412 515.685L658.88 583.579L1055.08 1150.3H892.476L569.165 687.854V687.828Z', 20 | })) 21 | }) 22 | -------------------------------------------------------------------------------- /src/use-pwm-badge.ts: -------------------------------------------------------------------------------- 1 | import type { Ref } from 'vue' 2 | import type { OTPInputProps } from './types' 3 | import { computed, onMounted, onUnmounted, ref, watch } from 'vue' 4 | 5 | const PWM_BADGE_MARGIN_RIGHT = 18 6 | const PWM_BADGE_SPACE_WIDTH_PX = 40 7 | const PWM_BADGE_SPACE_WIDTH = `${PWM_BADGE_SPACE_WIDTH_PX}px` 8 | 9 | const PASSWORD_MANAGERS_SELECTORS = [ 10 | '[data-lastpass-icon-root]', // LastPass 11 | 'com-1password-button', // 1Password 12 | '[data-dashlanecreated]', // Dashlane 13 | '[style$="2147483647 !important;"]', // Bitwarden 14 | ].join(',') 15 | 16 | export function usePasswordManagerBadge({ 17 | containerRef, 18 | inputRef, 19 | pushPasswordManagerStrategy, 20 | isFocused, 21 | }: { 22 | containerRef: Ref 23 | inputRef: Ref 24 | pushPasswordManagerStrategy: OTPInputProps['pushPasswordManagerStrategy'] 25 | isFocused: Ref 26 | }) { 27 | const pwmMetadata = ref({ 28 | done: false, 29 | refocused: false, 30 | }) 31 | 32 | const hasPWMBadge = ref(false) 33 | const hasPWMBadgeSpace = ref(false) 34 | const done = ref(false) 35 | 36 | const willPushPWMBadge = computed(() => { 37 | if (pushPasswordManagerStrategy === 'none') { 38 | return false 39 | } 40 | 41 | const increaseWidthCase 42 | = (pushPasswordManagerStrategy === 'increase-width' 43 | || pushPasswordManagerStrategy === 'experimental-no-flickering') 44 | && hasPWMBadge.value 45 | && hasPWMBadgeSpace.value 46 | 47 | return increaseWidthCase 48 | }) 49 | 50 | const trackPWMBadge = () => { 51 | const container = containerRef.value 52 | const input = inputRef.value 53 | if ( 54 | !container 55 | || !input 56 | || done.value 57 | || pushPasswordManagerStrategy === 'none' 58 | ) { 59 | return 60 | } 61 | 62 | const elementToCompare = container 63 | 64 | const rightCornerX 65 | = elementToCompare.getBoundingClientRect().left 66 | + elementToCompare.offsetWidth 67 | const centereredY 68 | = elementToCompare.getBoundingClientRect().top 69 | + elementToCompare.offsetHeight / 2 70 | const x = rightCornerX - PWM_BADGE_MARGIN_RIGHT 71 | const y = centereredY 72 | 73 | const pmws = document.querySelectorAll(PASSWORD_MANAGERS_SELECTORS) 74 | 75 | if (pmws.length === 0) { 76 | const maybeBadgeEl = document.elementFromPoint(x, y) 77 | 78 | if (maybeBadgeEl === container) { 79 | return 80 | } 81 | } 82 | 83 | hasPWMBadge.value = true 84 | done.value = true 85 | 86 | if (!pwmMetadata.value.refocused && document.activeElement === input) { 87 | const sel = [input.selectionStart, input.selectionEnd] 88 | input.blur() 89 | input.focus() 90 | input.setSelectionRange(sel[0]!, sel[1]!) 91 | 92 | pwmMetadata.value.refocused = true 93 | } 94 | } 95 | 96 | const checkHasSpace = () => { 97 | const container = containerRef.value 98 | if (!container || pushPasswordManagerStrategy === 'none') { 99 | return 100 | } 101 | 102 | const viewportWidth = window.innerWidth 103 | const distanceToRightEdge 104 | = viewportWidth - container.getBoundingClientRect().right 105 | hasPWMBadgeSpace.value = distanceToRightEdge >= PWM_BADGE_SPACE_WIDTH_PX 106 | } 107 | 108 | let spaceInterval: number 109 | 110 | onMounted(() => { 111 | checkHasSpace() 112 | spaceInterval = setInterval(checkHasSpace, 1000) 113 | }) 114 | 115 | onUnmounted(() => { 116 | clearInterval(spaceInterval) 117 | }) 118 | 119 | watch([isFocused, inputRef], (newValues, _, onInvalidate) => { 120 | const [newIsFocused, newInputRef] = newValues 121 | const _isFocused = newIsFocused || document.activeElement === newInputRef 122 | 123 | if (pushPasswordManagerStrategy === 'none' || !_isFocused) { 124 | return 125 | } 126 | 127 | const t1 = setTimeout(trackPWMBadge, 0) 128 | const t2 = setTimeout(trackPWMBadge, 2000) 129 | const t3 = setTimeout(trackPWMBadge, 5000) 130 | const t4 = setTimeout(() => { 131 | done.value = true 132 | }, 6000) 133 | 134 | onInvalidate(() => { 135 | clearTimeout(t1) 136 | clearTimeout(t2) 137 | clearTimeout(t3) 138 | clearTimeout(t4) 139 | }) 140 | }) 141 | 142 | return { hasPWMBadge, willPushPWMBadge, PWM_BADGE_SPACE_WIDTH } 143 | } 144 | -------------------------------------------------------------------------------- /website/app/assets/css/tailwind.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "tw-animate-css"; 3 | 4 | @custom-variant dark (&:is(.dark *)); 5 | 6 | @theme inline { 7 | --radius-sm: calc(var(--radius) - 4px); 8 | --radius-md: calc(var(--radius) - 2px); 9 | --radius-lg: var(--radius); 10 | --radius-xl: calc(var(--radius) + 4px); 11 | --color-background: var(--background); 12 | --color-foreground: var(--foreground); 13 | --color-card: var(--card); 14 | --color-card-foreground: var(--card-foreground); 15 | --color-popover: var(--popover); 16 | --color-popover-foreground: var(--popover-foreground); 17 | --color-primary: var(--primary); 18 | --color-primary-foreground: var(--primary-foreground); 19 | --color-secondary: var(--secondary); 20 | --color-secondary-foreground: var(--secondary-foreground); 21 | --color-muted: var(--muted); 22 | --color-muted-foreground: var(--muted-foreground); 23 | --color-accent: var(--accent); 24 | --color-accent-foreground: var(--accent-foreground); 25 | --color-destructive: var(--destructive); 26 | --color-destructive-foreground: var(--destructive-foreground); 27 | --color-border: var(--border); 28 | --color-input: var(--input); 29 | --color-ring: var(--ring); 30 | --color-chart-1: var(--chart-1); 31 | --color-chart-2: var(--chart-2); 32 | --color-chart-3: var(--chart-3); 33 | --color-chart-4: var(--chart-4); 34 | --color-chart-5: var(--chart-5); 35 | --color-sidebar: var(--sidebar); 36 | --color-sidebar-foreground: var(--sidebar-foreground); 37 | --color-sidebar-primary: var(--sidebar-primary); 38 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 39 | --color-sidebar-accent: var(--sidebar-accent); 40 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 41 | --color-sidebar-border: var(--sidebar-border); 42 | --color-sidebar-ring: var(--sidebar-ring); 43 | } 44 | 45 | :root { 46 | --radius: 0.625rem; 47 | --background: oklch(1 0 0); 48 | --foreground: oklch(0.145 0 0); 49 | --card: oklch(1 0 0); 50 | --card-foreground: oklch(0.145 0 0); 51 | --popover: oklch(1 0 0); 52 | --popover-foreground: oklch(0.145 0 0); 53 | --primary: oklch(0.205 0 0); 54 | --primary-foreground: oklch(0.985 0 0); 55 | --secondary: oklch(0.97 0 0); 56 | --secondary-foreground: oklch(0.205 0 0); 57 | --muted: oklch(0.97 0 0); 58 | --muted-foreground: oklch(0.556 0 0); 59 | --accent: oklch(0.97 0 0); 60 | --accent-foreground: oklch(0.205 0 0); 61 | --destructive: oklch(0.577 0.245 27.325); 62 | --destructive-foreground: oklch(0.577 0.245 27.325); 63 | --border: oklch(0.922 0 0); 64 | --input: oklch(0.922 0 0); 65 | --ring: oklch(0.708 0 0); 66 | --chart-1: oklch(0.646 0.222 41.116); 67 | --chart-2: oklch(0.6 0.118 184.704); 68 | --chart-3: oklch(0.398 0.07 227.392); 69 | --chart-4: oklch(0.828 0.189 84.429); 70 | --chart-5: oklch(0.769 0.188 70.08); 71 | --sidebar: oklch(0.985 0 0); 72 | --sidebar-foreground: oklch(0.145 0 0); 73 | --sidebar-primary: oklch(0.205 0 0); 74 | --sidebar-primary-foreground: oklch(0.985 0 0); 75 | --sidebar-accent: oklch(0.97 0 0); 76 | --sidebar-accent-foreground: oklch(0.205 0 0); 77 | --sidebar-border: oklch(0.922 0 0); 78 | --sidebar-ring: oklch(0.708 0 0); 79 | } 80 | 81 | .dark { 82 | --background: oklch(0.145 0 0); 83 | --foreground: oklch(0.985 0 0); 84 | --card: oklch(0.145 0 0); 85 | --card-foreground: oklch(0.985 0 0); 86 | --popover: oklch(0.145 0 0); 87 | --popover-foreground: oklch(0.985 0 0); 88 | --primary: oklch(0.985 0 0); 89 | --primary-foreground: oklch(0.205 0 0); 90 | --secondary: oklch(0.269 0 0); 91 | --secondary-foreground: oklch(0.985 0 0); 92 | --muted: oklch(0.269 0 0); 93 | --muted-foreground: oklch(0.708 0 0); 94 | --accent: oklch(0.269 0 0); 95 | --accent-foreground: oklch(0.985 0 0); 96 | --destructive: oklch(0.396 0.141 25.723); 97 | --destructive-foreground: oklch(0.637 0.237 25.331); 98 | --border: oklch(0.269 0 0); 99 | --input: oklch(0.269 0 0); 100 | --ring: oklch(0.439 0 0); 101 | --chart-1: oklch(0.488 0.243 264.376); 102 | --chart-2: oklch(0.696 0.17 162.48); 103 | --chart-3: oklch(0.769 0.188 70.08); 104 | --chart-4: oklch(0.627 0.265 303.9); 105 | --chart-5: oklch(0.645 0.246 16.439); 106 | --sidebar: oklch(0.205 0 0); 107 | --sidebar-foreground: oklch(0.985 0 0); 108 | --sidebar-primary: oklch(0.488 0.243 264.376); 109 | --sidebar-primary-foreground: oklch(0.985 0 0); 110 | --sidebar-accent: oklch(0.269 0 0); 111 | --sidebar-accent-foreground: oklch(0.985 0 0); 112 | --sidebar-border: oklch(0.269 0 0); 113 | --sidebar-ring: oklch(0.439 0 0); 114 | } 115 | 116 | @layer base { 117 | * { 118 | @apply border-border outline-ring/50; 119 | } 120 | body { 121 | @apply bg-background text-foreground; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OTP Input for Vue 2 | 3 | https://github.com/wobsoriano/vue-input-otp/assets/13049130/c5080f41-f411-4d38-aa57-d04d90c832c3 4 | 5 | One-time passcode Input. Accessible & unstyled. Based on the [React version](https://github.com/guilhermerodz/input-otp) by [guilhermerodz](https://github.com/guilhermerodz). 6 | 7 | ## Installation 8 | 9 | ```bash 10 | npm install vue-input-otp 11 | ``` 12 | 13 | ## Usage 14 | 15 | ```vue 16 | 22 | 23 | 30 | ``` 31 | 32 | ## Default example 33 | 34 | The example below uses `tailwindcss`, `shadcn-vue`, `tailwind-merge` and `clsx`: 35 | 36 | ```vue 37 | 41 | 42 | 62 | ``` 63 | 64 | ```vue 65 | 71 | 72 | 94 | ``` 95 | 96 | ```ts 97 | import type { ClassValue } from 'clsx' 98 | // tailwind.config.ts for the blinking caret animation. 99 | // Small utility to merge class names. 100 | import { clsx } from 'clsx' 101 | 102 | import { twMerge } from 'tailwind-merge' 103 | 104 | const config = { 105 | theme: { 106 | extend: { 107 | keyframes: { 108 | 'caret-blink': { 109 | '0%,70%,100%': { opacity: '1' }, 110 | '20%,50%': { opacity: '0' }, 111 | }, 112 | }, 113 | animation: { 114 | 'caret-blink': 'caret-blink 1.2s ease-out infinite', 115 | }, 116 | }, 117 | }, 118 | } 119 | 120 | export function cn(...inputs: ClassValue[]) { 121 | return twMerge(clsx(inputs)) 122 | } 123 | ``` 124 | 125 | ## How it works 126 | 127 | There's currently no native OTP/2FA/MFA input in HTML, which means people are either going with 1. a simple input design or 2. custom designs like this one. This library works by rendering an invisible input as a sibling of the slots, contained by a `relative`ly positioned parent (the container root called OTPInput). 128 | 129 | ## API Reference 130 | 131 | ### OTPInput 132 | 133 | The root container. Define settings for the input via props. Then, pass in child elements to create the slots. 134 | 135 | ##### Props 136 | 137 | |Name|Description|Type|Values|Default| 138 | |:----|:----|:----|:----|:----| 139 | |`maxlength`|The number of slots.|`number`|`-`|`-`| 140 | |`containerClass`|The class for the root container.|`string`|`-`|`-`| 141 | |`textAlign`|Where is the text located within the input. Affects click-holding or long-press behavior.|`string`|`left`, `right`, `center`|`center`| 142 | |`inputmode`|Virtual keyboard appearance on mobile.|`string`|`numeric`, `text`|`numeric`| 143 | |`pushPasswordManagerStrategy`|Detect Password Managers and shift their badges to the right side, outside the input.|`string`|`increase-width`, `none`|`increase-width`| 144 | 145 | #### Slots 146 | 147 | |Name|Description|Props| 148 | |:----|:----|:----| 149 | |`default`|The slots to be rendered.|`slots: SlotProps[], isFocused: boolean, isHovering: boolean`| 150 | 151 | #### Events 152 | 153 | |Name|Description|Parameters| 154 | |:----|:----|:----| 155 | |`complete`|Emitted when the input is complete.|`value: string`| 156 | 157 | ## Examples 158 | 159 |
160 | Automatic form submission on OTP completion 161 | 162 | ```vue 163 | 177 | 178 | 186 | ``` 187 | 188 |
189 | 190 |
191 | Automatically focus the input when the page loads 192 | 193 | ```vue 194 | 197 | 198 | 204 | ``` 205 | 206 |
207 | 208 | ## Caveats 209 | 210 | See list of caveats in the original implementation [here](https://github.com/guilhermerodz/input-otp/blob/master/README.md#caveats). 211 | 212 | ## License 213 | 214 | MIT 215 | -------------------------------------------------------------------------------- /src/OTPInput.vue: -------------------------------------------------------------------------------- 1 | 438 | 439 | 487 | --------------------------------------------------------------------------------