Skip to content

refactor(VOtpInput): single overlay input#22803

Open
Yanis-Riani wants to merge 16 commits intovuetifyjs:devfrom
Yanis-Riani:feat/22659-headless-otp-input
Open

refactor(VOtpInput): single overlay input#22803
Yanis-Riani wants to merge 16 commits intovuetifyjs:devfrom
Yanis-Riani:feat/22659-headless-otp-input

Conversation

@Yanis-Riani
Copy link
Copy Markdown
Contributor

@Yanis-Riani Yanis-Riani commented Apr 17, 2026

Description

resolves #22659

resolves #18427

Refactors VOtpInput to use a single hidden overlay input instead of individual inputs per slot. This architecture fixes copy/paste, RTL support, and improves screen reader support. The component is fully backward-compatible slots can be customized via the default slot using new sub-components.

This component can be manipulated to other used case then an OTP such as a PIN code, Bank codes (BSB, IBAN, ...) and maybe other but it may need changes on the pattern side to check the whole input and not each character

New Sub Components

  • VOtpField
    • Props:
      • index (Number) - Index of slot
  • VOtpGroup
    • Props:
      • merged (Boolean | null) default = null - Merge child VOtpField , fallback to root component merged
  • VOtpSeparator
    • Slots:
      • default - to create custom separator (replace text)

Root Component

  • Props
    • pattern (’numeric’ | ‘alpha’ | ‘alphanumeric’ | RegExp) default = undefined - filter accepted character, fallback to type = number if it’s the case then pattern = 'numeric' for backward-compatibility reason
    • merged (Boolean) default = false - define VOtpGroup default behavior, when merge no divider can be displayed
  • Slots
    • default - to custom slots layout with sub components: VOtpField , VOtpGroup , VOtpSeparator
    • divider - Slot for custom divider content, Receives { index } with the divider position
  • Other
    • Add RTL Support

Markup:

<template>
  <v-app>
    <v-container>
      <p class="mb-2">Default (auto-layout)</p>
      <v-otp-input v-model="value" divider="" />

      <p class="mt-6 mb-2">Divider slot with icon</p>
      <v-otp-input v-model="value" variant="outlined">
        <template #divider="{ index }">                              
          <v-icon v-if="index === 2" icon="mdi-minus" />
          <v-icon v-else icon="mdi-chevron-right" />                                   
        </template>   
      </v-otp-input>

      <p class="mt-6 mb-2">Default merged (root merge prop)</p>
      <v-otp-input v-model="value" divider="i" merged variant="solo"/>

      <p class="mt-6 mb-2">Custom layout — style[3]-[3]</p>
      <v-otp-input v-model="value" divider="-" merged variant="solo-inverted">
        <v-otp-group :merged="false">
          <v-otp-field :index="0" />
          <v-otp-field :index="1" />
          <v-otp-field :index="2" />
        </v-otp-group>
        <v-otp-separator class="custom-separator" />
        <v-otp-group>
          <v-otp-field :index="3" />
          <v-otp-field :index="4" />
          <v-otp-field :index="5" />
        </v-otp-group>
      </v-otp-input>

      <p class="mt-6 mb-2">Custom layout — [2]-[4] no merge</p>
      <v-otp-input v-model="value" variant="filled">
        <v-otp-group>
          <v-otp-field :index="0" />
          <v-otp-field :index="1" />
        </v-otp-group>
        <v-otp-separator>
          <v-icon icon="mdi-minus" />
        </v-otp-separator>
        <v-otp-group>
          <v-otp-field :index="2" />
          <v-otp-field :index="3" />
          <v-otp-field :index="4" />
          <v-otp-field :index="5" />
        </v-otp-group>
      </v-otp-input>

      <p class="mt-6 mb-2">RTL</p>
      <v-locale-provider rtl>
        <v-otp-input v-model="value" variant="plain"/>
      </v-locale-provider>
    </v-container>
  </v-app>
</template>

<script setup lang="ts">
  import { ref } from 'vue';
  const value = ref<string>('1234')
</script>

<style scoped>
.custom-separator {
  width: 0;
  height: 0;
  border-top: 6px solid transparent;
  border-bottom: 6px solid transparent;
  border-left: 8px solid currentColor;
  opacity: 0.4;
  transition: opacity 0.2s, transform 0.2s;
}

.custom-separator:hover {
  opacity: 1;
  transform: scale(1.4);
}
</style>

@Yanis-Riani Yanis-Riani changed the title Refactor(VOtpInput): single overlay input refactor(VOtpInput): single overlay input Apr 17, 2026
@Yanis-Riani
Copy link
Copy Markdown
Contributor Author

I have a question about the local translation, since one aria label has been touch did i need to clear all the translation ?
also did i need to update the doc by myself ?

@J-Sek
Copy link
Copy Markdown
Contributor

J-Sek commented Apr 17, 2026

yes to both

  • docs & examples will need some updates
  • all other translations need to be updated - I can do it for you if you don't have any AI tool handy, it is quite tedious to change by hand

additionally

  • include new props in new-in.json and their descriptions in VOtpInput.json
  • list new sub-components in page-to-api.json
  • new components should get their *.json files with props/slots/emits descriptions

btw. I am not sure if it counts as breaking or not.. would be a shame to push it to v5

@J-Sek J-Sek added T: feature A new feature C: VOtpInput a11y Accessibility issue labels Apr 17, 2026
Copy link
Copy Markdown
Contributor

@J-Sek J-Sek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a quick feedback

Comment thread packages/vuetify/src/components/VOtpInput/VOtpSeparator.tsx Outdated
Comment thread packages/vuetify/src/components/VOtpInput/VOtpInput.tsx Outdated
@Yanis-Riani
Copy link
Copy Markdown
Contributor Author

Ok thank i will fix that tomorrow, i can do the French translation since im a native and for the doc i will see if i could updated it properly, i will probably need some feedback about it.

@J-Sek
Copy link
Copy Markdown
Contributor

J-Sek commented Apr 17, 2026

I am curious if we could make it all backward compatible. This would mean we need to preserve the original "otp" key in translations and add a new one "otpInput" or sth.

@Yanis-Riani Yanis-Riani force-pushed the feat/22659-headless-otp-input branch 2 times, most recently from d7d3ac3 to 5351f5c Compare April 18, 2026 05:57
@Yanis-Riani
Copy link
Copy Markdown
Contributor Author

I am curious if we could make it all backward compatible. This would mean we need to preserve the original "otp" key in translations and add a new one "otpInput" or sth.

I’m confused about what the use case would be for keeping the old key. isn't just dead code ? the only part of the component that needs an aria-label is the hidden input, we can add is an aria-description, but the old pattern with index no longer makes much sense here.

@Yanis-Riani
Copy link
Copy Markdown
Contributor Author

Also, I figured out that once the input is selected, it intercepts the hover CSS of VField. The only workaround I found to this is to add an hover prop to VField and add onmousemove to the hidden input. It works well, but I wanted your opinion on that since it needs to change VField and it's probably is out of scope for now.

Replace the N separate <input> elements with a single hidden <input>
overlaying all visual slots, inspired by the input-otp library.

- Single real input improves a11y, native autofill (autocomplete="one-time-code"),
  and mobile keyboard support
- Forced 1-char range selection ([i, i+1]) drives the active-slot highlight
  via onSelectionChange; mirrors stored in renderSelectionStart/End refs
- Visual slots are now <div> + <span> inside VField (no more input elements)
- Fake caret rendered on active empty slot with CSS blink animation
- deleteContentForward (Delete key) intercepted in beforeinput to keep
  selection at slot i instead of drifting backward
- Modifier + Backspace/Delete intercepted in keydown (before browser
  converts to deleteContentBackward due to forced range selection)
- Android IME fallback: word/line delete inputTypes handled in beforeinput
- renderSelectionStart/End updated directly after setSelectionRange to avoid
  missed selectionchange on empty inputs in Chromium
- Tests adapted: single input selector, active slot via .v-field--focused,
  clickSlot helper for selection-based slot targeting
- Add VOtpField, VOtpGroup, VOtpSeparator sub-components for custom layouts
- Add merged prop to VOtpInput and VOtpGroup for merged-border style
- Propagate merged/divider from root via provide/inject context
- Auto-layout uses sub-components internally
- VOtpGroup uses display:contents (non-merged) for equal field sizing
- VOtpGroup uses flex:N (merged, N=field count) for proportional sizing
- Merge mode collapses adjacent borders via margin-inline-start:-1px
- Restore focused field border via z-index and border-inline-start-width
Add focusAt(index) to context for programmatic slot selection.
VOtpField emits data-otp-index + onClick for pre-focus slot clicks.
Input uses onMousedown + elementsFromPoint for post-focus detection.
Pointer-events toggle (none/all via :focus-within) enables native
hover before focus and right-click paste after focus.
Fix hover borders in merged groups with z-index layering.
Clamp focusAt index to input.value.length for empty slot clicks.
Remove individual field box-shadows in merged groups to prevent
overlapping artifacts. Re-apply elevation on the group container
via :has() for solo variants. Add overlay on focused solo/plain
fields. Adapt tests to pointer-events toggle and add coverage for
click-to-select, empty slot redirect, merged layout and custom
sub-component layouts.
@Yanis-Riani Yanis-Riani force-pushed the feat/22659-headless-otp-input branch from 5351f5c to 27c8eec Compare April 23, 2026 02:40
@Yanis-Riani
Copy link
Copy Markdown
Contributor Author

The doc is updated, and I made some style changes when focus-all is enabled. Take a look.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a11y Accessibility issue C: VOtpInput T: feature A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants