From a171876c89e5599ea551deeffcbe4e9cfe17858f Mon Sep 17 00:00:00 2001 From: Sai Sridhar Date: Tue, 4 Aug 2026 16:41:30 +0530 Subject: [PATCH 1/5] feat(Otp): add Otp compound component over createOtp --- .changeset/feat-otp-component-519.md | 30 ++ .../0/src/components/Otp/OtpHiddenInput.vue | 45 +++ packages/0/src/components/Otp/OtpItem.vue | 188 +++++++++++ packages/0/src/components/Otp/OtpRoot.vue | 203 ++++++++++++ packages/0/src/components/Otp/index.test.ts | 296 ++++++++++++++++++ packages/0/src/components/Otp/index.ts | 136 ++++++++ .../0/src/components/a11y.browser.test.ts | 2 + packages/0/src/components/fixtures/Otp.vue | 18 ++ packages/0/src/components/index.ts | 1 + packages/0/src/locale/messages/en/index.ts | 4 + packages/0/src/maturity.json | 6 +- packages/0/src/surface.test.ts | 2 +- 12 files changed, 928 insertions(+), 3 deletions(-) create mode 100644 .changeset/feat-otp-component-519.md create mode 100644 packages/0/src/components/Otp/OtpHiddenInput.vue create mode 100644 packages/0/src/components/Otp/OtpItem.vue create mode 100644 packages/0/src/components/Otp/OtpRoot.vue create mode 100644 packages/0/src/components/Otp/index.test.ts create mode 100644 packages/0/src/components/Otp/index.ts create mode 100644 packages/0/src/components/fixtures/Otp.vue diff --git a/.changeset/feat-otp-component-519.md b/.changeset/feat-otp-component-519.md new file mode 100644 index 0000000000..d27cc517ff --- /dev/null +++ b/.changeset/feat-otp-component-519.md @@ -0,0 +1,30 @@ +--- +"@vuetify/v0": minor +--- + +feat(Otp): add Otp compound component over createOtp (#519) + +`createOtp` has shipped a complete, tested headless primitive for one-time-password / +verification-code values since 1.0.0-alpha.5, but there was no Vue component wrapping +it — consumers had to hand-roll the rendering, focus management, and keyboard/paste +wiring themselves. + +Added `Otp.Root`, `Otp.Item`, and `Otp.HiddenInput`, following the same +Root/Item/HiddenInput shape as `Rating` and `Slider`: + +- `Otp.Root` creates the `createOtp` context, bridges v-model, renders a `role="group"` + container with a locale-driven default `aria-label` ("Verification code"), and tracks + each `Otp.Item`'s element so items can move focus between each other. +- `Otp.Item` renders one character box (`` + by default). Typing a pattern-accepted character auto-advances focus to the next box; + Backspace on an empty box moves focus back and clears the previous box; arrow keys move + focus directly between boxes; pasting distributes the clipboard text across boxes + starting at the box it was pasted into (`createOtp.distribute`), matching what + `maxlength` would otherwise silently truncate. Each box gets its own + `aria-label` ("Digit N of length"). +- `Otp.HiddenInput` mirrors `Rating.HiddenInput` — auto-rendered when `name` is set, for + native form submission. + +Registered in the advisory a11y sweep (`packages/0/src/components/fixtures/Otp.vue`) — +clean, no axe violations. Bumped `Otp`'s entry in `maturity.json` from `draft` to +`preview`. diff --git a/packages/0/src/components/Otp/OtpHiddenInput.vue b/packages/0/src/components/Otp/OtpHiddenInput.vue new file mode 100644 index 0000000000..6fa4fdb02c --- /dev/null +++ b/packages/0/src/components/Otp/OtpHiddenInput.vue @@ -0,0 +1,45 @@ +/** + * @module OtpHiddenInput + * + * @see https://0.vuetifyjs.com/components/forms/otp + * + * @remarks + * Hidden native input for form submission. Auto-rendered by Root + * when `name` prop is provided. Same pattern as Rating.HiddenInput. + */ + + + + + + diff --git a/packages/0/src/components/Otp/OtpItem.vue b/packages/0/src/components/Otp/OtpItem.vue new file mode 100644 index 0000000000..0141931580 --- /dev/null +++ b/packages/0/src/components/Otp/OtpItem.vue @@ -0,0 +1,188 @@ +/** + * @module OtpItem + * + * @see https://0.vuetifyjs.com/components/forms/otp + * + * @remarks + * A single character box within an OTP field. Consumes Otp context, + * renders its character from `value[index]`, and handles auto-advance + * on input, backspace-back navigation, arrow-key movement between + * boxes, and paste distribution across siblings starting at this box. + */ + + + + + + diff --git a/packages/0/src/components/Otp/OtpRoot.vue b/packages/0/src/components/Otp/OtpRoot.vue new file mode 100644 index 0000000000..5e29dd5217 --- /dev/null +++ b/packages/0/src/components/Otp/OtpRoot.vue @@ -0,0 +1,203 @@ +/** + * @module OtpRoot + * + * @see https://0.vuetifyjs.com/components/forms/otp + * + * @remarks + * Root component for one-time-password / verification-code inputs. Creates + * OTP context via createOtp, provides it to child components (Item, + * HiddenInput), bridges v-model, and tracks per-item element refs so + * Item can move focus between boxes (auto-advance, backspace-back, paste + * distribution). + */ + + + + + + diff --git a/packages/0/src/components/Otp/index.test.ts b/packages/0/src/components/Otp/index.test.ts new file mode 100644 index 0000000000..6b648c9bdf --- /dev/null +++ b/packages/0/src/components/Otp/index.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from 'vitest' + +import { Otp } from './index' + +// Utilities +import { mount } from '@vue/test-utils' +import { h, nextTick, ref } from 'vue' + +// Types +import type { VueWrapper } from '@vue/test-utils' + +function mountOtp (options: { + props?: Record + model?: ReturnType> + length?: number +} = {}) { + let wrapper: VueWrapper + + const props: Record = { + ...(options.model && { + 'modelValue': options.model.value, + 'onUpdate:modelValue': (v: unknown) => { + options.model!.value = v as string + wrapper.setProps({ modelValue: v }) + }, + }), + length: options.length ?? 6, + ...options.props, + } + + wrapper = mount(Otp.Root, { + props, + slots: { + default: () => Array.from({ length: options.length ?? 6 }, (_, i) => + h(Otp.Item as any, { key: i, index: i }), + ), + }, + attachTo: document.body, + }) + + return { + wrapper, + groupEl: () => wrapper.find('[role="group"]'), + itemEls: () => wrapper.findAll('input'), + wait: () => nextTick(), + } +} + +describe('otp', () => { + describe('rendering', () => { + it('should render a role=group container', () => { + const { groupEl } = mountOtp() + expect(groupEl().exists()).toBe(true) + }) + + it('should render one input per length', () => { + const { itemEls } = mountOtp({ length: 4 }) + expect(itemEls()).toHaveLength(4) + }) + + it('should default the aria-label', () => { + const { groupEl } = mountOtp() + expect(groupEl().attributes('aria-label')).toBe('Verification code') + }) + + it('should use a custom ariaLabel', () => { + const { groupEl } = mountOtp({ props: { ariaLabel: 'Enter your code' } }) + expect(groupEl().attributes('aria-label')).toBe('Enter your code') + }) + + it('should suppress aria-label when ariaLabelledby is set', () => { + const { groupEl } = mountOtp({ props: { ariaLabelledby: 'code-heading' } }) + expect(groupEl().attributes('aria-label')).toBeUndefined() + expect(groupEl().attributes('aria-labelledby')).toBe('code-heading') + }) + + it('should label each item with its position', () => { + const { itemEls } = mountOtp({ length: 3 }) + const labels = itemEls().map(el => el.attributes('aria-label')) + expect(labels).toEqual(['Digit 1 of 3', 'Digit 2 of 3', 'Digit 3 of 3']) + }) + }) + + describe('v-model', () => { + it('should render each character in its box', async () => { + const model = ref('12') + const { itemEls, wait } = mountOtp({ model, length: 4 }) + await wait() + + const values = itemEls().map(el => (el.element as HTMLInputElement).value) + expect(values).toEqual(['1', '2', '', '']) + }) + + it('should write a typed character and advance focus', async () => { + const model = ref('') + const { itemEls, wait } = mountOtp({ model, length: 3 }) + await wait() + + await itemEls()[0]!.setValue('4') + await wait() + + expect(model.value).toBe('4') + expect(document.activeElement).toBe(itemEls()[1]!.element) + }) + + it('should ignore a character the pattern rejects', async () => { + const model = ref('') + const { itemEls, wait } = mountOtp({ model, length: 3 }) + await wait() + + await itemEls()[0]!.setValue('a') + await wait() + + expect(model.value).toBe('') + }) + + it('should prevent a rejected keystroke via beforeinput', async () => { + const { itemEls, wait } = mountOtp({ length: 3 }) + await wait() + + const input = itemEls()[0]!.element as HTMLInputElement + const event = new Event('beforeinput', { cancelable: true }) as InputEvent + Object.defineProperty(event, 'data', { value: 'a' }) + Object.defineProperty(event, 'target', { value: input }) + input.dispatchEvent(event) + + expect(event.defaultPrevented).toBe(true) + }) + + it('should allow an accepted keystroke via beforeinput', async () => { + const { itemEls, wait } = mountOtp({ length: 3 }) + await wait() + + const input = itemEls()[0]!.element as HTMLInputElement + const event = new Event('beforeinput', { cancelable: true }) as InputEvent + Object.defineProperty(event, 'data', { value: '4' }) + Object.defineProperty(event, 'target', { value: input }) + input.dispatchEvent(event) + + expect(event.defaultPrevented).toBe(false) + }) + + it('should clear a filled box on native backspace without moving focus', async () => { + const model = ref('1') + const { itemEls, wait } = mountOtp({ model, length: 3 }) + await wait() + + await itemEls()[0]!.setValue('') + await wait() + + expect(model.value).toBe('') + expect(document.activeElement).not.toBe(itemEls()[1]!.element) + }) + + it('should move focus back and clear the previous box on backspace from empty', async () => { + const model = ref('1') + const { itemEls, wait } = mountOtp({ model, length: 3 }) + await wait() + + await itemEls()[1]!.trigger('keydown', { key: 'Backspace' }) + await wait() + + expect(model.value).toBe('') + expect(document.activeElement).toBe(itemEls()[0]!.element) + }) + + it('should move focus with arrow keys', async () => { + const { itemEls, wait } = mountOtp({ length: 3 }) + await wait() + + await itemEls()[0]!.trigger('keydown', { key: 'ArrowRight' }) + await wait() + expect(document.activeElement).toBe(itemEls()[1]!.element) + + await itemEls()[1]!.trigger('keydown', { key: 'ArrowLeft' }) + await wait() + expect(document.activeElement).toBe(itemEls()[0]!.element) + }) + + it('should distribute pasted text across boxes and focus past the last written box', async () => { + const model = ref('') + const { itemEls, wait } = mountOtp({ model, length: 4 }) + await wait() + + const dataTransfer = { getData: () => '1234' } + await itemEls()[0]!.trigger('paste', { clipboardData: dataTransfer }) + await wait() + + expect(model.value).toBe('1234') + }) + }) + + describe('disabled state', () => { + it('should mark the group aria-disabled', () => { + const { groupEl } = mountOtp({ props: { disabled: true } }) + expect(groupEl().attributes('aria-disabled')).toBe('true') + }) + + it('should not write while disabled', async () => { + const model = ref('') + const { itemEls, wait } = mountOtp({ model, props: { disabled: true } }) + await wait() + + await itemEls()[0]!.setValue('4') + await wait() + + expect(model.value).toBe('') + }) + }) + + describe('readonly state', () => { + it('should not write while readonly', async () => { + const model = ref('') + const { itemEls, wait } = mountOtp({ model, props: { readonly: true } }) + await wait() + + await itemEls()[0]!.setValue('4') + await wait() + + expect(model.value).toBe('') + }) + }) + + describe('name prop', () => { + it('should render a hidden input with the joined value', async () => { + const model = ref('123') + const { wrapper, wait } = mountOtp({ model, props: { name: 'code' } }) + await wait() + + const hidden = wrapper.find('input[type="hidden"]') + expect(hidden.exists()).toBe(true) + expect(hidden.attributes('name')).toBe('code') + expect((hidden.element as HTMLInputElement).value).toBe('123') + }) + + it('should not render a hidden input without name', () => { + const { wrapper } = mountOtp() + expect(wrapper.find('input[type="hidden"]').exists()).toBe(false) + }) + }) + + describe('onComplete', () => { + it('should mark data-complete when the value reaches length', async () => { + const model = ref('12345') + const { groupEl, itemEls, wait } = mountOtp({ model, length: 6 }) + await wait() + + await itemEls()[5]!.setValue('6') + await wait() + + expect(groupEl().attributes('data-complete')).toBe('true') + }) + + it('should mark aria-busy while an async onComplete is pending', async () => { + let resolve!: (value: boolean) => void + const pending = new Promise(r => { + resolve = r + }) + const model = ref('1') + const { groupEl, itemEls, wait } = mountOtp({ + model, + length: 2, + props: { onComplete: () => pending }, + }) + await wait() + + await itemEls()[1]!.setValue('2') + await wait() + + expect(groupEl().attributes('aria-busy')).toBe('true') + + resolve(true) + await wait() + await wait() + + expect(groupEl().attributes('aria-busy')).toBeUndefined() + }) + + it('should reject and clear when onComplete resolves false', async () => { + const model = ref('') + const { itemEls, wait } = mountOtp({ + model, + length: 2, + props: { onComplete: () => false }, + }) + await wait() + + await itemEls()[0]!.setValue('1') + await wait() + await itemEls()[1]!.setValue('2') + await wait() + + expect(model.value).toBe('') + }) + }) +}) diff --git a/packages/0/src/components/Otp/index.ts b/packages/0/src/components/Otp/index.ts new file mode 100644 index 0000000000..b16e58db8a --- /dev/null +++ b/packages/0/src/components/Otp/index.ts @@ -0,0 +1,136 @@ +export { default as OtpHiddenInput } from './OtpHiddenInput.vue' + +export { default as OtpItem } from './OtpItem.vue' + +export { default as OtpRoot } from './OtpRoot.vue' +export { provideOtpRoot, useOtpRoot } from './OtpRoot.vue' + +export type { OtpHiddenInputProps } from './OtpHiddenInput.vue' +export type { OtpItemProps, OtpItemSlotProps, OtpItemState } from './OtpItem.vue' +export type { OtpRootContext, OtpRootProps, OtpRootSlotProps } from './OtpRoot.vue' + +// Context +import HiddenInput from './OtpHiddenInput.vue' +import Item from './OtpItem.vue' +import Root from './OtpRoot.vue' + +/** + * Otp component with sub-components for building one-time-password / + * verification-code inputs. + * + * Provides a headless, accessible OTP field with auto-advance on input, + * backspace-back and arrow-key navigation between boxes, paste + * distribution across boxes, per-character pattern matching, and a + * decisional async `onComplete` hook. Uses `createOtp` internally for + * value management. + * + * @see https://0.vuetifyjs.com/components/forms/otp + * + * @example + * ```vue + * + * + * + * ``` + */ +export const Otp = { + /** + * Root component for OTP fields. + * + * Creates OTP context via `createOtp`, provides it to child + * components, and bridges v-model. Tracks per-item element refs so + * Item can move focus between boxes. When `name` prop is provided, + * automatically renders a hidden input for form submission. + * + * @see https://0.vuetifyjs.com/components/forms/otp + * + * @example + * ```vue + * + * + * + * ``` + */ + Root, + /** + * A single character box within an OTP field. + * + * Consumes Otp context and renders its character from the value at + * `index`. Handles auto-advance to the next box on input, + * backspace-back when empty, arrow-key movement between boxes, and + * paste distribution across siblings starting at this box. Exposes + * fill state via `data-state` for CSS-only styling. + * + * @see https://0.vuetifyjs.com/components/forms/otp#anatomy + * + * @example + * ```vue + * + * + * + * ``` + */ + Item, + /** + * Hidden native input for form submission. + * + * Auto-rendered by Root when the `name` prop is provided. Submits + * the current joined OTP value as part of the form. Can also be + * used explicitly for custom form integration scenarios where you + * need manual control over the hidden input placement. + * + * @see https://0.vuetifyjs.com/components/forms/otp + * @internal + * + * @example + * ```vue + * + * + * + * ``` + */ + HiddenInput, +} diff --git a/packages/0/src/components/a11y.browser.test.ts b/packages/0/src/components/a11y.browser.test.ts index caddb88b4e..111e786fac 100644 --- a/packages/0/src/components/a11y.browser.test.ts +++ b/packages/0/src/components/a11y.browser.test.ts @@ -30,6 +30,7 @@ import ImageFixture from './fixtures/Image.vue' import InputFixture from './fixtures/Input.vue' import LocaleFixture from './fixtures/Locale.vue' import NumberFieldFixture from './fixtures/NumberField.vue' +import OtpFixture from './fixtures/Otp.vue' import OverflowFixture from './fixtures/Overflow.vue' import PaginationFixture from './fixtures/Pagination.vue' import PopoverFixture from './fixtures/Popover.vue' @@ -134,6 +135,7 @@ const FIXTURES = { Input: InputFixture, Locale: LocaleFixture, NumberField: NumberFieldFixture, + Otp: OtpFixture, Overflow: OverflowFixture, Pagination: PaginationFixture, Popover: PopoverFixture, diff --git a/packages/0/src/components/fixtures/Otp.vue b/packages/0/src/components/fixtures/Otp.vue new file mode 100644 index 0000000000..6436714ede --- /dev/null +++ b/packages/0/src/components/fixtures/Otp.vue @@ -0,0 +1,18 @@ + + + diff --git a/packages/0/src/components/index.ts b/packages/0/src/components/index.ts index 07fa208bac..27dfd8515e 100644 --- a/packages/0/src/components/index.ts +++ b/packages/0/src/components/index.ts @@ -16,6 +16,7 @@ export * from './Image' export * from './Input' export * from './Locale' export * from './NumberField' +export * from './Otp' export * from './Overflow' export * from './Pagination' export * from './Popover' diff --git a/packages/0/src/locale/messages/en/index.ts b/packages/0/src/locale/messages/en/index.ts index 053cbda18f..7e6c843e87 100644 --- a/packages/0/src/locale/messages/en/index.ts +++ b/packages/0/src/locale/messages/en/index.ts @@ -54,6 +54,10 @@ export default { increment: 'Increment', label: 'Number', }, + Otp: { + itemLabel: 'Digit {index} of {length}', + label: 'Verification code', + }, Pagination: { currentPage: 'Page {page}, current', first: 'First page', diff --git a/packages/0/src/maturity.json b/packages/0/src/maturity.json index bf9be7fbd8..e79136d2e6 100644 --- a/packages/0/src/maturity.json +++ b/packages/0/src/maturity.json @@ -561,8 +561,10 @@ "description": "Numeric input with increment/decrement buttons, drag-to-scrub, and locale-aware formatting via `Intl.NumberFormat` for currency, percent, and units." }, "Otp": { - "level": "draft", - "category": "forms" + "level": "preview", + "since": "1.3.0", + "category": "forms", + "description": "Headless one-time-password / verification-code input with auto-advance, backspace-back navigation, and paste distribution across boxes." }, "Radio": { "level": "stable", diff --git a/packages/0/src/surface.test.ts b/packages/0/src/surface.test.ts index 7ffa7f034a..2ca4ce533e 100644 --- a/packages/0/src/surface.test.ts +++ b/packages/0/src/surface.test.ts @@ -40,7 +40,7 @@ const COMPOSABLES = [ ] const COMPONENTS = [ - 'AlertDialog', 'AlertDialogAction', 'AlertDialogActivator', 'AlertDialogCancel', 'AlertDialogClose', 'AlertDialogContent', 'AlertDialogDescription', 'AlertDialogRoot', 'AlertDialogTitle', 'AspectRatio', 'Atom', 'Avatar', 'AvatarFallback', 'AvatarGroup', 'AvatarImage', 'AvatarIndicator', 'AvatarRoot', 'Breadcrumbs', 'BreadcrumbsActivator', 'BreadcrumbsDivider', 'BreadcrumbsEllipsis', 'BreadcrumbsItem', 'BreadcrumbsLink', 'BreadcrumbsList', 'BreadcrumbsPage', 'BreadcrumbsRoot', 'Button', 'ButtonContent', 'ButtonGroup', 'ButtonHiddenInput', 'ButtonIcon', 'ButtonLoading', 'ButtonRoot', 'Carousel', 'CarouselIndicator', 'CarouselItem', 'CarouselLiveRegion', 'CarouselNext', 'CarouselPrevious', 'CarouselProgress', 'CarouselRoot', 'CarouselViewport', 'Checkbox', 'CheckboxGroup', 'CheckboxHiddenInput', 'CheckboxIndicator', 'CheckboxRoot', 'CheckboxSelectAll', 'Collapsible', 'CollapsibleActivator', 'CollapsibleContent', 'CollapsibleCue', 'CollapsibleRoot', 'Combobox', 'ComboboxActivator', 'ComboboxContent', 'ComboboxControl', 'ComboboxCue', 'ComboboxDescription', 'ComboboxEmpty', 'ComboboxError', 'ComboboxItem', 'ComboboxRoot', 'Dialog', 'DialogActivator', 'DialogClose', 'DialogContent', 'DialogDescription', 'DialogRoot', 'DialogTitle', 'ExpansionPanel', 'ExpansionPanelActivator', 'ExpansionPanelContent', 'ExpansionPanelCue', 'ExpansionPanelGroup', 'ExpansionPanelHeader', 'ExpansionPanelRoot', 'Form', 'Group', 'GroupItem', 'GroupRoot', 'Image', 'ImageFallback', 'ImageImg', 'ImagePlaceholder', 'ImageRoot', 'Input', 'InputControl', 'InputDescription', 'InputError', 'InputRoot', 'Locale', 'NumberField', 'NumberFieldControl', 'NumberFieldDecrement', 'NumberFieldDescription', 'NumberFieldError', 'NumberFieldIncrement', 'NumberFieldRoot', 'NumberFieldScrub', 'Overflow', 'OverflowIndicator', 'OverflowItem', 'OverflowRoot', 'Pagination', 'PaginationEllipsis', 'PaginationFirst', 'PaginationItem', 'PaginationLast', 'PaginationNext', 'PaginationPrev', 'PaginationRoot', 'PaginationStatus', 'Popover', 'PopoverActivator', 'PopoverContent', 'PopoverRoot', 'Portal', 'Presence', 'Progress', 'ProgressBuffer', 'ProgressFill', 'ProgressHiddenInput', 'ProgressLabel', 'ProgressRoot', 'ProgressTrack', 'ProgressValue', 'Radio', 'RadioGroup', 'RadioHiddenInput', 'RadioIndicator', 'RadioRoot', 'Rating', 'RatingHiddenInput', 'RatingItem', 'RatingRoot', 'Scrim', 'Select', 'SelectActivator', 'SelectContent', 'SelectCue', 'SelectItem', 'SelectPlaceholder', 'SelectRoot', 'SelectValue', 'Selection', 'SelectionItem', 'SelectionRoot', 'Single', 'SingleItem', 'SingleRoot', 'Slider', 'SliderHiddenInput', 'SliderRange', 'SliderRoot', 'SliderThumb', 'SliderTrack', 'Snackbar', 'SnackbarAnnouncer', 'SnackbarClose', 'SnackbarContent', 'SnackbarPortal', 'SnackbarQueue', 'SnackbarRoot', 'Splitter', 'SplitterHandle', 'SplitterPanel', 'SplitterRoot', 'Step', 'StepItem', 'StepRoot', 'Switch', 'SwitchGroup', 'SwitchHiddenInput', 'SwitchRoot', 'SwitchSelectAll', 'SwitchThumb', 'SwitchTrack', 'Tabs', 'TabsItem', 'TabsList', 'TabsPanel', 'TabsRoot', 'Theme', 'Toggle', 'ToggleGroup', 'ToggleIndicator', 'ToggleRoot', 'Tooltip', 'TooltipActivator', 'TooltipContent', 'TooltipRoot', 'Treeview', 'TreeviewActivator', 'TreeviewCheckbox', 'TreeviewContent', 'TreeviewCue', 'TreeviewGroup', 'TreeviewIndicator', 'TreeviewItem', 'TreeviewList', 'TreeviewRoot', 'TreeviewSelectAll', 'provideAlertDialogContext', 'provideAvatarGroup', 'provideAvatarRoot', 'provideBreadcrumbsRoot', 'provideButtonGroup', 'provideButtonRoot', 'provideCarouselRoot', 'provideCheckboxGroup', 'provideCheckboxRoot', 'provideCollapsible', 'provideComboboxContext', 'provideDialogContext', 'provideExpansionPanelGroup', 'provideExpansionPanelRoot', 'provideGroupRoot', 'provideImageRoot', 'provideInputRoot', 'provideNumberFieldRoot', 'provideOverflowRoot', 'providePaginationControls', 'providePaginationItems', 'providePaginationRoot', 'providePopoverContext', 'provideProgressRoot', 'provideRadioGroup', 'provideRadioRoot', 'provideRatingRoot', 'provideSelectContext', 'provideSelectionRoot', 'provideSingleRoot', 'provideSliderRoot', 'provideSnackbarQueueContext', 'provideSnackbarRootContext', 'provideSplitterRoot', 'provideStepRoot', 'provideSwitchGroup', 'provideSwitchRoot', 'provideTabsRoot', 'provideToggleGroup', 'provideToggleRoot', 'provideTooltipRoot', 'provideTreeviewItem', 'provideTreeviewList', 'provideTreeviewRoot', 'useAlertDialogContext', 'useAvatarGroup', 'useAvatarRoot', 'useBreadcrumbsRoot', 'useButtonGroup', 'useButtonRoot', 'useCarouselRoot', 'useCheckboxGroup', 'useCheckboxRoot', 'useCollapsible', 'useComboboxContext', 'useDialogContext', 'useExpansionPanelGroup', 'useExpansionPanelRoot', 'useGroupRoot', 'useImageRoot', 'useInputRoot', 'useNumberFieldRoot', 'useOverflowRoot', 'usePaginationControls', 'usePaginationItems', 'usePaginationRoot', 'usePopoverContext', 'useProgressRoot', 'useRadioGroup', 'useRadioRoot', 'useRatingRoot', 'useSelectContext', 'useSelectionRoot', 'useSingleRoot', 'useSliderRoot', 'useSnackbarQueueContext', 'useSnackbarRootContext', 'useSplitterRoot', 'useStepRoot', 'useSwitchGroup', 'useSwitchRoot', 'useTabsRoot', 'useToggleGroup', 'useToggleRoot', 'useTooltipRoot', 'useTreeviewItem', 'useTreeviewList', 'useTreeviewRoot', + 'AlertDialog', 'AlertDialogAction', 'AlertDialogActivator', 'AlertDialogCancel', 'AlertDialogClose', 'AlertDialogContent', 'AlertDialogDescription', 'AlertDialogRoot', 'AlertDialogTitle', 'AspectRatio', 'Atom', 'Avatar', 'AvatarFallback', 'AvatarGroup', 'AvatarImage', 'AvatarIndicator', 'AvatarRoot', 'Breadcrumbs', 'BreadcrumbsActivator', 'BreadcrumbsDivider', 'BreadcrumbsEllipsis', 'BreadcrumbsItem', 'BreadcrumbsLink', 'BreadcrumbsList', 'BreadcrumbsPage', 'BreadcrumbsRoot', 'Button', 'ButtonContent', 'ButtonGroup', 'ButtonHiddenInput', 'ButtonIcon', 'ButtonLoading', 'ButtonRoot', 'Carousel', 'CarouselIndicator', 'CarouselItem', 'CarouselLiveRegion', 'CarouselNext', 'CarouselPrevious', 'CarouselProgress', 'CarouselRoot', 'CarouselViewport', 'Checkbox', 'CheckboxGroup', 'CheckboxHiddenInput', 'CheckboxIndicator', 'CheckboxRoot', 'CheckboxSelectAll', 'Collapsible', 'CollapsibleActivator', 'CollapsibleContent', 'CollapsibleCue', 'CollapsibleRoot', 'Combobox', 'ComboboxActivator', 'ComboboxContent', 'ComboboxControl', 'ComboboxCue', 'ComboboxDescription', 'ComboboxEmpty', 'ComboboxError', 'ComboboxItem', 'ComboboxRoot', 'Dialog', 'DialogActivator', 'DialogClose', 'DialogContent', 'DialogDescription', 'DialogRoot', 'DialogTitle', 'ExpansionPanel', 'ExpansionPanelActivator', 'ExpansionPanelContent', 'ExpansionPanelCue', 'ExpansionPanelGroup', 'ExpansionPanelHeader', 'ExpansionPanelRoot', 'Form', 'Group', 'GroupItem', 'GroupRoot', 'Image', 'ImageFallback', 'ImageImg', 'ImagePlaceholder', 'ImageRoot', 'Input', 'InputControl', 'InputDescription', 'InputError', 'InputRoot', 'Locale', 'NumberField', 'NumberFieldControl', 'NumberFieldDecrement', 'NumberFieldDescription', 'NumberFieldError', 'NumberFieldIncrement', 'NumberFieldRoot', 'NumberFieldScrub', 'Otp', 'OtpHiddenInput', 'OtpItem', 'OtpRoot', 'Overflow', 'OverflowIndicator', 'OverflowItem', 'OverflowRoot', 'Pagination', 'PaginationEllipsis', 'PaginationFirst', 'PaginationItem', 'PaginationLast', 'PaginationNext', 'PaginationPrev', 'PaginationRoot', 'PaginationStatus', 'Popover', 'PopoverActivator', 'PopoverContent', 'PopoverRoot', 'Portal', 'Presence', 'Progress', 'ProgressBuffer', 'ProgressFill', 'ProgressHiddenInput', 'ProgressLabel', 'ProgressRoot', 'ProgressTrack', 'ProgressValue', 'Radio', 'RadioGroup', 'RadioHiddenInput', 'RadioIndicator', 'RadioRoot', 'Rating', 'RatingHiddenInput', 'RatingItem', 'RatingRoot', 'Scrim', 'Select', 'SelectActivator', 'SelectContent', 'SelectCue', 'SelectItem', 'SelectPlaceholder', 'SelectRoot', 'SelectValue', 'Selection', 'SelectionItem', 'SelectionRoot', 'Single', 'SingleItem', 'SingleRoot', 'Slider', 'SliderHiddenInput', 'SliderRange', 'SliderRoot', 'SliderThumb', 'SliderTrack', 'Snackbar', 'SnackbarAnnouncer', 'SnackbarClose', 'SnackbarContent', 'SnackbarPortal', 'SnackbarQueue', 'SnackbarRoot', 'Splitter', 'SplitterHandle', 'SplitterPanel', 'SplitterRoot', 'Step', 'StepItem', 'StepRoot', 'Switch', 'SwitchGroup', 'SwitchHiddenInput', 'SwitchRoot', 'SwitchSelectAll', 'SwitchThumb', 'SwitchTrack', 'Tabs', 'TabsItem', 'TabsList', 'TabsPanel', 'TabsRoot', 'Theme', 'Toggle', 'ToggleGroup', 'ToggleIndicator', 'ToggleRoot', 'Tooltip', 'TooltipActivator', 'TooltipContent', 'TooltipRoot', 'Treeview', 'TreeviewActivator', 'TreeviewCheckbox', 'TreeviewContent', 'TreeviewCue', 'TreeviewGroup', 'TreeviewIndicator', 'TreeviewItem', 'TreeviewList', 'TreeviewRoot', 'TreeviewSelectAll', 'provideAlertDialogContext', 'provideAvatarGroup', 'provideAvatarRoot', 'provideBreadcrumbsRoot', 'provideButtonGroup', 'provideButtonRoot', 'provideCarouselRoot', 'provideCheckboxGroup', 'provideCheckboxRoot', 'provideCollapsible', 'provideComboboxContext', 'provideDialogContext', 'provideExpansionPanelGroup', 'provideExpansionPanelRoot', 'provideGroupRoot', 'provideImageRoot', 'provideInputRoot', 'provideNumberFieldRoot', 'provideOtpRoot', 'provideOverflowRoot', 'providePaginationControls', 'providePaginationItems', 'providePaginationRoot', 'providePopoverContext', 'provideProgressRoot', 'provideRadioGroup', 'provideRadioRoot', 'provideRatingRoot', 'provideSelectContext', 'provideSelectionRoot', 'provideSingleRoot', 'provideSliderRoot', 'provideSnackbarQueueContext', 'provideSnackbarRootContext', 'provideSplitterRoot', 'provideStepRoot', 'provideSwitchGroup', 'provideSwitchRoot', 'provideTabsRoot', 'provideToggleGroup', 'provideToggleRoot', 'provideTooltipRoot', 'provideTreeviewItem', 'provideTreeviewList', 'provideTreeviewRoot', 'useAlertDialogContext', 'useAvatarGroup', 'useAvatarRoot', 'useBreadcrumbsRoot', 'useButtonGroup', 'useButtonRoot', 'useCarouselRoot', 'useCheckboxGroup', 'useCheckboxRoot', 'useCollapsible', 'useComboboxContext', 'useDialogContext', 'useExpansionPanelGroup', 'useExpansionPanelRoot', 'useGroupRoot', 'useImageRoot', 'useInputRoot', 'useNumberFieldRoot', 'useOtpRoot', 'useOverflowRoot', 'usePaginationControls', 'usePaginationItems', 'usePaginationRoot', 'usePopoverContext', 'useProgressRoot', 'useRadioGroup', 'useRadioRoot', 'useRatingRoot', 'useSelectContext', 'useSelectionRoot', 'useSingleRoot', 'useSliderRoot', 'useSnackbarQueueContext', 'useSnackbarRootContext', 'useSplitterRoot', 'useStepRoot', 'useSwitchGroup', 'useSwitchRoot', 'useTabsRoot', 'useToggleGroup', 'useToggleRoot', 'useTooltipRoot', 'useTreeviewItem', 'useTreeviewList', 'useTreeviewRoot', ] const UTILITIES = [ From 3d9e7f462088469cb11f7f9c4f6a8d84ec685761 Mon Sep 17 00:00:00 2001 From: John Leider Date: Thu, 20 Aug 2026 09:35:53 -0500 Subject: [PATCH 2/5] fix(Otp): keep length/pattern reactive, guard locked input, and correct paste focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrap length/pattern as getters into createOtp and expose pattern as a Ref on the context — bare destructured props froze them at mount (Rating precedent), leaving inputmode and accepts() stale after a pattern prop change. - Re-sync the box DOM when a keystroke is rejected by the pattern or arrives while disabled/readonly/validating — the model doesn't change, so Vue skips the patch and the stale character lingered in the input. - Focus the next empty box after paste from distribute's start + written count — index + written overshot when the paste landed before index on a shorter value. - Route the Atom template ref through AtomExpose + toElement, track item elements as Element, and cast to HTMLElement only at the focus site. - aria-disabled on the group is a concrete boolean per the house ARIA contract; maturity since starts null until a release ships it. --- packages/0/src/components/Otp/OtpItem.vue | 35 ++++++++++++++++++----- packages/0/src/components/Otp/OtpRoot.vue | 23 ++++++++------- packages/0/src/maturity.json | 2 +- 3 files changed, 42 insertions(+), 18 deletions(-) diff --git a/packages/0/src/components/Otp/OtpItem.vue b/packages/0/src/components/Otp/OtpItem.vue index 0141931580..36c3bacec3 100644 --- a/packages/0/src/components/Otp/OtpItem.vue +++ b/packages/0/src/components/Otp/OtpItem.vue @@ -20,11 +20,14 @@ // Composables import { useLocale } from '#v0/composables/useLocale' + // Transformers + import { toElement } from '#v0/composables/toElement' + // Utilities import { mergeProps, onBeforeUnmount, toRef, useAttrs, useTemplateRef, watch } from 'vue' // Types - import type { AtomProps } from '#v0/components/Atom' + import type { AtomExpose, AtomProps } from '#v0/components/Atom' export type OtpItemState = 'filled' | 'empty' @@ -84,10 +87,11 @@ const root = useOtpRoot(namespace) const locale = useLocale() - const itemRef = useTemplateRef<{ element: HTMLElement | null }>('item') + const atomRef = useTemplateRef('item') + const el = toRef(() => toElement(atomRef.value?.element) ?? null) - watch(() => itemRef.value?.element, el => { - root.registerItemEl(index, (el as HTMLElement | null) ?? null) + watch(el, next => { + root.registerItemEl(index, next) }) onBeforeUnmount(() => { @@ -105,6 +109,12 @@ function onInput (e: Event) { const target = e.target as HTMLInputElement + + if (root.isDisabled.value || root.isReadonly.value || root.isValidating.value) { + target.value = charValue.value + return + } + const text = target.value if (text === '') { @@ -113,7 +123,14 @@ } const char = text.at(-1)! - if (!root.accepts(char)) return + + if (!root.accepts(char)) { + // A rejected write leaves the model untouched, so Vue skips the patch + // and the stale keystroke would linger in the DOM. + target.value = charValue.value + return + } + root.write(index, char) root.focusItem(index + 1) } @@ -148,7 +165,11 @@ e.preventDefault() const text = e.clipboardData?.getData('text') ?? '' const written = root.distribute(text, index) - if (written > 0) root.focusItem(index + written) + // distribute splices at min(value.length, index), so the next empty box + // sits at that start plus the written count — `index + written` would + // overshoot when the paste lands before `index` on a shorter value. + // Computed from the pre-write value: the model ref may update async. + if (written > 0) root.focusItem(Math.min(root.value.value.length, index) + written) } const slotProps = toRef((): OtpItemSlotProps => ({ @@ -158,7 +179,7 @@ isReadonly: root.isReadonly.value, attrs: { 'type': 'text', - 'inputmode': root.pattern === 'numeric' ? 'numeric' : 'text', + 'inputmode': root.pattern.value === 'numeric' ? 'numeric' : 'text', 'autocomplete': 'one-time-code', 'maxlength': 1, 'value': charValue.value, diff --git a/packages/0/src/components/Otp/OtpRoot.vue b/packages/0/src/components/Otp/OtpRoot.vue index 5e29dd5217..8aa6f63385 100644 --- a/packages/0/src/components/Otp/OtpRoot.vue +++ b/packages/0/src/components/Otp/OtpRoot.vue @@ -35,13 +35,13 @@ /** Form field name */ readonly name?: string /** Resolved per-character pattern */ - readonly pattern: OtpPattern + pattern: Readonly> /** Whether interaction is disabled */ isDisabled: Readonly> /** Whether the field is readonly */ isReadonly: Readonly> /** Register an Item's focusable element for focus movement between boxes */ - registerItemEl: (index: number, el: HTMLElement | null) => void + registerItemEl: (index: number, el: Element | null) => void /** Move focus to the item at `index`, clamped to [0, length). No-op if unregistered. */ focusItem: (index: number) => void } @@ -90,7 +90,7 @@ 'role': 'group' 'aria-label': string | undefined 'aria-labelledby': string | undefined - 'aria-disabled': true | undefined + 'aria-disabled': boolean 'aria-busy': true | undefined 'data-disabled': true | undefined 'data-readonly': true | undefined @@ -128,16 +128,18 @@ const otp = createOtp({ value: model, - length, - pattern, + // Wrap reactive props as getters — createOtp reads length/pattern via + // toValue(), so bare scalar snapshots would freeze them at mount. + length: toRef(() => length), + pattern: toRef(() => pattern), disabled: () => toValue(disabled), readonly: () => toValue(_readonly), onComplete, }) - const itemEls = new Map() + const itemEls = new Map() - function registerItemEl (index: number, el: HTMLElement | null) { + function registerItemEl (index: number, el: Element | null) { if (el) itemEls.set(index, el) else itemEls.delete(index) } @@ -145,7 +147,8 @@ function focusItem (index: number) { const max = toValue(otp.length) - 1 const clamped = Math.min(Math.max(index, 0), max) - itemEls.get(clamped)?.focus() + const el = itemEls.get(clamped) as HTMLElement | undefined + el?.focus() } const isDisabled = toRef(() => toValue(disabled)) @@ -154,7 +157,7 @@ const context: OtpRootContext = { ...otp, name, - pattern, + pattern: toRef(() => pattern), isDisabled, isReadonly, registerItemEl, @@ -176,7 +179,7 @@ 'role': 'group', 'aria-label': ariaLabelledby ? undefined : (ariaLabel || (locale.ti('Otp.label') ?? 'Verification code')), 'aria-labelledby': ariaLabelledby || undefined, - 'aria-disabled': isDisabled.value ? true : undefined, + 'aria-disabled': isDisabled.value, 'aria-busy': otp.isValidating.value ? true : undefined, 'data-disabled': isDisabled.value ? true : undefined, 'data-readonly': isReadonly.value ? true : undefined, diff --git a/packages/0/src/maturity.json b/packages/0/src/maturity.json index 63aae9e2b0..ead8084834 100644 --- a/packages/0/src/maturity.json +++ b/packages/0/src/maturity.json @@ -562,7 +562,7 @@ }, "Otp": { "level": "preview", - "since": "1.3.0", + "since": null, "category": "forms", "description": "Headless one-time-password / verification-code input with auto-advance, backspace-back navigation, and paste distribution across boxes." }, From 1e9be9209ef60792965f81bf39291df7a295b3a3 Mon Sep 17 00:00:00 2001 From: John Leider Date: Thu, 20 Aug 2026 09:36:04 -0500 Subject: [PATCH 3/5] test(Otp): cover paste edges, arrow bounds, pattern variants, and locked-input sync Adds regression coverage for the branches codecov flagged: paste filtering/truncation/next-empty-box focus and the disabled guard, arrow navigation clamped at both ends, backspace no-op on the first box, alphanumeric/custom-RegExp/reactive pattern handling, DOM re-sync of rejected and validation-locked keystrokes, single-fire and refire of onComplete, hidden input value/disabled syncing, and the exposed focusItem clamp. --- packages/0/src/components/Otp/index.test.ts | 296 +++++++++++++++++++- 1 file changed, 295 insertions(+), 1 deletion(-) diff --git a/packages/0/src/components/Otp/index.test.ts b/packages/0/src/components/Otp/index.test.ts index 6b648c9bdf..dcc366d697 100644 --- a/packages/0/src/components/Otp/index.test.ts +++ b/packages/0/src/components/Otp/index.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Otp } from './index' @@ -114,6 +114,17 @@ describe('otp', () => { expect(model.value).toBe('') }) + it('should revert the box DOM value when the pattern rejects the keystroke', async () => { + const model = ref('') + const { itemEls, wait } = mountOtp({ model, length: 3 }) + await wait() + + await itemEls()[0]!.setValue('a') + await wait() + + expect((itemEls()[0]!.element as HTMLInputElement).value).toBe('') + }) + it('should prevent a rejected keystroke via beforeinput', async () => { const { itemEls, wait } = mountOtp({ length: 3 }) await wait() @@ -140,6 +151,19 @@ describe('otp', () => { expect(event.defaultPrevented).toBe(false) }) + it('should ignore multi-character beforeinput data', async () => { + const { itemEls, wait } = mountOtp({ length: 3 }) + await wait() + + const input = itemEls()[0]!.element as HTMLInputElement + const event = new Event('beforeinput', { cancelable: true }) as InputEvent + Object.defineProperty(event, 'data', { value: 'ab' }) + Object.defineProperty(event, 'target', { value: input }) + input.dispatchEvent(event) + + expect(event.defaultPrevented).toBe(false) + }) + it('should clear a filled box on native backspace without moving focus', async () => { const model = ref('1') const { itemEls, wait } = mountOtp({ model, length: 3 }) @@ -164,6 +188,20 @@ describe('otp', () => { expect(document.activeElement).toBe(itemEls()[0]!.element) }) + it('should no-op backspace on an empty first box', async () => { + const model = ref('') + const { itemEls, wait } = mountOtp({ model, length: 3 }) + await wait() + + const first = itemEls()[0]!.element as HTMLInputElement + first.focus() + await itemEls()[0]!.trigger('keydown', { key: 'Backspace' }) + await wait() + + expect(model.value).toBe('') + expect(document.activeElement).toBe(first) + }) + it('should move focus with arrow keys', async () => { const { itemEls, wait } = mountOtp({ length: 3 }) await wait() @@ -177,6 +215,36 @@ describe('otp', () => { expect(document.activeElement).toBe(itemEls()[0]!.element) }) + it('should clamp arrow navigation at both ends', async () => { + const { itemEls, wait } = mountOtp({ length: 3 }) + await wait() + + const first = itemEls()[0]!.element as HTMLInputElement + const last = itemEls()[2]!.element as HTMLInputElement + + first.focus() + await itemEls()[0]!.trigger('keydown', { key: 'ArrowLeft' }) + await wait() + expect(document.activeElement).toBe(first) + + last.focus() + await itemEls()[2]!.trigger('keydown', { key: 'ArrowRight' }) + await wait() + expect(document.activeElement).toBe(last) + }) + + it('should keep focus on the last box when typing into it', async () => { + const model = ref('12') + const { itemEls, wait } = mountOtp({ model, length: 3 }) + await wait() + + await itemEls()[2]!.setValue('3') + await wait() + + expect(model.value).toBe('123') + expect(document.activeElement).toBe(itemEls()[2]!.element) + }) + it('should distribute pasted text across boxes and focus past the last written box', async () => { const model = ref('') const { itemEls, wait } = mountOtp({ model, length: 4 }) @@ -190,12 +258,138 @@ describe('otp', () => { }) }) + describe('paste', () => { + it('should filter rejected characters out of pasted text', async () => { + const model = ref('') + const { itemEls, wait } = mountOtp({ model, length: 4 }) + await wait() + + const dataTransfer = { getData: () => '1a2b' } + await itemEls()[0]!.trigger('paste', { clipboardData: dataTransfer }) + await wait() + + expect(model.value).toBe('12') + expect(document.activeElement).toBe(itemEls()[2]!.element) + }) + + it('should ignore a paste with no accepted characters', async () => { + const model = ref('') + const { itemEls, wait } = mountOtp({ model, length: 4 }) + await wait() + + const first = itemEls()[0]!.element as HTMLInputElement + first.focus() + const dataTransfer = { getData: () => 'abc' } + await itemEls()[0]!.trigger('paste', { clipboardData: dataTransfer }) + await wait() + + expect(model.value).toBe('') + expect(document.activeElement).toBe(first) + }) + + it('should truncate a paste longer than length', async () => { + const model = ref('') + const { itemEls, wait } = mountOtp({ model, length: 4 }) + await wait() + + const dataTransfer = { getData: () => '123456789' } + await itemEls()[0]!.trigger('paste', { clipboardData: dataTransfer }) + await wait() + + expect(model.value).toBe('1234') + expect(document.activeElement).toBe(itemEls()[3]!.element) + }) + + it('should focus the next empty box when pasting into a later box with a short value', async () => { + const model = ref('') + const { itemEls, wait } = mountOtp({ model, length: 6 }) + await wait() + + const dataTransfer = { getData: () => '12' } + await itemEls()[3]!.trigger('paste', { clipboardData: dataTransfer }) + await wait() + + expect(model.value).toBe('12') + expect(document.activeElement).toBe(itemEls()[2]!.element) + }) + + it('should not distribute while disabled', async () => { + const model = ref('') + const { itemEls, wait } = mountOtp({ model, props: { disabled: true } }) + await wait() + + const dataTransfer = { getData: () => '1234' } + await itemEls()[0]!.trigger('paste', { clipboardData: dataTransfer }) + await wait() + + expect(model.value).toBe('') + }) + }) + + describe('pattern', () => { + it('should set inputmode numeric for the numeric pattern', () => { + const { itemEls } = mountOtp() + expect(itemEls()[0]!.attributes('inputmode')).toBe('numeric') + }) + + it('should accept letters with the alphanumeric pattern', async () => { + const model = ref('') + const { itemEls, wait } = mountOtp({ model, props: { pattern: 'alphanumeric' } }) + await wait() + + expect(itemEls()[0]!.attributes('inputmode')).toBe('text') + + await itemEls()[0]!.setValue('a') + await wait() + + expect(model.value).toBe('a') + }) + + it('should apply a custom RegExp pattern per character', async () => { + const model = ref('') + const { itemEls, wait } = mountOtp({ model, props: { pattern: /^[0-7]$/ } }) + await wait() + + await itemEls()[0]!.setValue('8') + await wait() + expect(model.value).toBe('') + + await itemEls()[0]!.setValue('7') + await wait() + expect(model.value).toBe('7') + }) + + it('should react to a pattern prop change', async () => { + const model = ref('') + const { wrapper, itemEls, wait } = mountOtp({ model }) + await wait() + + expect(itemEls()[0]!.attributes('inputmode')).toBe('numeric') + + await wrapper.setProps({ pattern: 'alphabetic' }) + await wait() + + expect(itemEls()[0]!.attributes('inputmode')).toBe('text') + + await itemEls()[0]!.setValue('z') + await wait() + + expect(model.value).toBe('z') + }) + }) + describe('disabled state', () => { it('should mark the group aria-disabled', () => { const { groupEl } = mountOtp({ props: { disabled: true } }) expect(groupEl().attributes('aria-disabled')).toBe('true') }) + it('should mark items disabled and data-disabled', () => { + const { itemEls } = mountOtp({ props: { disabled: true } }) + expect(itemEls()[0]!.attributes('disabled')).toBeDefined() + expect(itemEls()[0]!.attributes('data-disabled')).toBe('true') + }) + it('should not write while disabled', async () => { const model = ref('') const { itemEls, wait } = mountOtp({ model, props: { disabled: true } }) @@ -209,6 +403,13 @@ describe('otp', () => { }) describe('readonly state', () => { + it('should mark the group and items data-readonly', () => { + const { groupEl, itemEls } = mountOtp({ props: { readonly: true } }) + expect(groupEl().attributes('data-readonly')).toBe('true') + expect(itemEls()[0]!.attributes('readonly')).toBeDefined() + expect(itemEls()[0]!.attributes('data-readonly')).toBe('true') + }) + it('should not write while readonly', async () => { const model = ref('') const { itemEls, wait } = mountOtp({ model, props: { readonly: true } }) @@ -233,6 +434,24 @@ describe('otp', () => { expect((hidden.element as HTMLInputElement).value).toBe('123') }) + it('should keep the hidden input in sync as the value changes', async () => { + const model = ref('') + const { wrapper, itemEls, wait } = mountOtp({ model, length: 3, props: { name: 'code' } }) + await wait() + + await itemEls()[0]!.setValue('4') + await wait() + + const hidden = wrapper.find('input[type="hidden"]') + expect((hidden.element as HTMLInputElement).value).toBe('4') + }) + + it('should disable the hidden input when disabled', () => { + const { wrapper } = mountOtp({ props: { name: 'code', disabled: true } }) + const hidden = wrapper.find('input[type="hidden"]') + expect(hidden.attributes('disabled')).toBeDefined() + }) + it('should not render a hidden input without name', () => { const { wrapper } = mountOtp() expect(wrapper.find('input[type="hidden"]').exists()).toBe(false) @@ -251,6 +470,40 @@ describe('otp', () => { expect(groupEl().attributes('data-complete')).toBe('true') }) + it('should call onComplete once with the joined value', async () => { + const onComplete = vi.fn() + const model = ref('') + const { itemEls, wait } = mountOtp({ model, length: 2, props: { onComplete } }) + await wait() + + await itemEls()[0]!.setValue('1') + await wait() + await itemEls()[1]!.setValue('2') + await wait() + + expect(onComplete).toHaveBeenCalledTimes(1) + expect(onComplete).toHaveBeenCalledWith('12') + }) + + it('should call onComplete again after clearing and refilling', async () => { + const onComplete = vi.fn() + const model = ref('1') + const { itemEls, wait } = mountOtp({ model, length: 2, props: { onComplete } }) + await wait() + + await itemEls()[1]!.setValue('2') + await wait() + expect(onComplete).toHaveBeenCalledTimes(1) + + await itemEls()[1]!.setValue('') + await wait() + await itemEls()[1]!.setValue('3') + await wait() + + expect(onComplete).toHaveBeenCalledTimes(2) + expect(onComplete).toHaveBeenLastCalledWith('13') + }) + it('should mark aria-busy while an async onComplete is pending', async () => { let resolve!: (value: boolean) => void const pending = new Promise(r => { @@ -276,6 +529,32 @@ describe('otp', () => { expect(groupEl().attributes('aria-busy')).toBeUndefined() }) + it('should revert a keystroke while an async onComplete is pending', async () => { + let resolve!: (value: boolean) => void + const pending = new Promise(r => { + resolve = r + }) + const model = ref('1') + const { itemEls, wait } = mountOtp({ + model, + length: 2, + props: { onComplete: () => pending }, + }) + await wait() + + await itemEls()[1]!.setValue('2') + await wait() + + await itemEls()[0]!.setValue('9') + await wait() + + expect(model.value).toBe('12') + expect((itemEls()[0]!.element as HTMLInputElement).value).toBe('1') + + resolve(true) + await wait() + }) + it('should reject and clear when onComplete resolves false', async () => { const model = ref('') const { itemEls, wait } = mountOtp({ @@ -293,4 +572,19 @@ describe('otp', () => { expect(model.value).toBe('') }) }) + + describe('expose', () => { + it('should expose focusItem clamped to the boxes', async () => { + const { wrapper, itemEls, wait } = mountOtp({ length: 3 }) + await wait() + + const vm = wrapper.vm as unknown as { focusItem: (index: number) => void } + + vm.focusItem(1) + expect(document.activeElement).toBe(itemEls()[1]!.element) + + vm.focusItem(99) + expect(document.activeElement).toBe(itemEls()[2]!.element) + }) + }) }) From 2bd7e889aeeba62cf1b9d84eb7af615600c4125b Mon Sep 17 00:00:00 2001 From: John Leider Date: Thu, 20 Aug 2026 14:01:01 -0500 Subject: [PATCH 4/5] test(Otp): close the codecov patch gap on OtpItem's guard branches Covers the exact lines codecov flagged: the readonly side of the disabled/readonly guards in beforeinput, keydown, and paste; backspace on a filled box deferring to the native input; unrelated keys falling through the keydown handler; paste without clipboard data; and the renderless slot path driving a consumer-rendered input through slot attrs. OtpItem now measures 100% statements/branches/lines locally. --- packages/0/src/components/Otp/index.test.ts | 117 ++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/packages/0/src/components/Otp/index.test.ts b/packages/0/src/components/Otp/index.test.ts index dcc366d697..9cf4443c00 100644 --- a/packages/0/src/components/Otp/index.test.ts +++ b/packages/0/src/components/Otp/index.test.ts @@ -587,4 +587,121 @@ describe('otp', () => { expect(document.activeElement).toBe(itemEls()[2]!.element) }) }) + + describe('guards', () => { + it('should skip beforeinput handling while disabled or readonly', async () => { + for (const prop of ['disabled', 'readonly'] as const) { + const { itemEls, wait } = mountOtp({ length: 2, props: { [prop]: true } }) + await wait() + + const input = itemEls()[0]!.element as HTMLInputElement + const event = new Event('beforeinput', { cancelable: true }) as InputEvent + Object.defineProperty(event, 'data', { value: 'x' }) + Object.defineProperty(event, 'target', { value: input }) + input.dispatchEvent(event) + + expect(event.defaultPrevented).toBe(false) + } + }) + + it('should ignore arrow keys while disabled or readonly', async () => { + for (const prop of ['disabled', 'readonly'] as const) { + const { itemEls, wait } = mountOtp({ length: 2, props: { [prop]: true } }) + await wait() + + await itemEls()[0]!.trigger('keydown', { key: 'ArrowRight' }) + await wait() + + expect(document.activeElement).not.toBe(itemEls()[1]!.element) + } + }) + + it('should leave backspace on a filled box to the native input', async () => { + const model = ref('12') + const { itemEls, wait } = mountOtp({ model, length: 3 }) + await wait() + + const input = itemEls()[1]!.element as HTMLInputElement + const event = new KeyboardEvent('keydown', { key: 'Backspace', cancelable: true }) + input.dispatchEvent(event) + await wait() + + expect(event.defaultPrevented).toBe(false) + expect(model.value).toBe('12') + }) + + it('should ignore unrelated keys', async () => { + const model = ref('1') + const { itemEls, wait } = mountOtp({ model, length: 3 }) + await wait() + + const first = itemEls()[0]!.element as HTMLInputElement + first.focus() + await itemEls()[0]!.trigger('keydown', { key: 'Tab' }) + await wait() + + expect(model.value).toBe('1') + expect(document.activeElement).toBe(first) + }) + + it('should not distribute while readonly', async () => { + const model = ref('') + const { itemEls, wait } = mountOtp({ model, props: { readonly: true } }) + await wait() + + const dataTransfer = { getData: () => '1234' } + await itemEls()[0]!.trigger('paste', { clipboardData: dataTransfer }) + await wait() + + expect(model.value).toBe('') + }) + + it('should treat a paste without clipboard data as empty', async () => { + const model = ref('') + const { itemEls, wait } = mountOtp({ model, length: 3 }) + await wait() + + await itemEls()[0]!.trigger('paste') + await wait() + + expect(model.value).toBe('') + }) + }) + + describe('renderless', () => { + it('should drive a consumer-rendered input through slot attrs', async () => { + const model = ref('') + + const wrapper: VueWrapper = mount(Otp.Root, { + props: { + 'length': 2, + 'modelValue': model.value, + 'onUpdate:modelValue': (v: unknown) => { + model.value = v as string + wrapper.setProps({ modelValue: v }) + }, + }, + slots: { + default: () => Array.from({ length: 2 }, (_, i) => + h(Otp.Item as any, { key: i, index: i, renderless: true }, { + default: (props: { attrs: Record, state: string }) => + h('input', { ...props.attrs, 'data-custom': props.state }), + }), + ), + }, + attachTo: document.body, + }) + await nextTick() + + const inputs = wrapper.findAll('input') + expect(inputs).toHaveLength(2) + expect(inputs[0]!.attributes('data-custom')).toBe('empty') + + await inputs[0]!.setValue('4') + await nextTick() + + expect(model.value).toBe('4') + expect(inputs[0]!.attributes('data-custom')).toBe('filled') + }) + }) }) From 5714c2423d35eccd5672dc2a7c60ac3d9a9e496d Mon Sep 17 00:00:00 2001 From: John Leider Date: Fri, 21 Aug 2026 11:08:57 -0500 Subject: [PATCH 5/5] refactor(Otp): align with convention canon - Declare the explicit 'update:model-value' emit alongside defineModel (vue-devtools event tracking; Input/Slider/Switch precedent). - Type the Root's imperative surface as an exported OtpRootExpose interface (Splitter precedent) and re-export it from the barrel. - Single-word locals per PHILOSOPHY 3.3: charValue -> char, with the typed character in onInput renamed to entered. --- packages/0/src/components/Otp/OtpItem.vue | 18 +++++++++--------- packages/0/src/components/Otp/OtpRoot.vue | 14 ++++++++++---- packages/0/src/components/Otp/index.ts | 2 +- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/0/src/components/Otp/OtpItem.vue b/packages/0/src/components/Otp/OtpItem.vue index 36c3bacec3..bc4f966226 100644 --- a/packages/0/src/components/Otp/OtpItem.vue +++ b/packages/0/src/components/Otp/OtpItem.vue @@ -98,8 +98,8 @@ root.registerItemEl(index, null) }) - const charValue = toRef(() => root.value.value[index] ?? '') - const state = toRef((): OtpItemState => charValue.value === '' ? 'empty' : 'filled') + const char = toRef(() => root.value.value[index] ?? '') + const state = toRef((): OtpItemState => char.value === '' ? 'empty' : 'filled') function onBeforeinput (e: InputEvent) { if (root.isDisabled.value || root.isReadonly.value) return @@ -111,7 +111,7 @@ const target = e.target as HTMLInputElement if (root.isDisabled.value || root.isReadonly.value || root.isValidating.value) { - target.value = charValue.value + target.value = char.value return } @@ -122,16 +122,16 @@ return } - const char = text.at(-1)! + const entered = text.at(-1)! - if (!root.accepts(char)) { + if (!root.accepts(entered)) { // A rejected write leaves the model untouched, so Vue skips the patch // and the stale keystroke would linger in the DOM. - target.value = charValue.value + target.value = char.value return } - root.write(index, char) + root.write(index, entered) root.focusItem(index + 1) } @@ -173,7 +173,7 @@ } const slotProps = toRef((): OtpItemSlotProps => ({ - value: charValue.value, + value: char.value, state: state.value, isDisabled: root.isDisabled.value, isReadonly: root.isReadonly.value, @@ -182,7 +182,7 @@ 'inputmode': root.pattern.value === 'numeric' ? 'numeric' : 'text', 'autocomplete': 'one-time-code', 'maxlength': 1, - 'value': charValue.value, + 'value': char.value, 'disabled': root.isDisabled.value || undefined, 'readonly': root.isReadonly.value || undefined, 'aria-label': locale.ti('Otp.itemLabel', { index: index + 1, length: root.length.value }) ?? `Digit ${index + 1} of ${root.length.value}`, diff --git a/packages/0/src/components/Otp/OtpRoot.vue b/packages/0/src/components/Otp/OtpRoot.vue index 8aa6f63385..cb1dffd9c1 100644 --- a/packages/0/src/components/Otp/OtpRoot.vue +++ b/packages/0/src/components/Otp/OtpRoot.vue @@ -98,6 +98,11 @@ } } + export interface OtpRootExpose { + /** Move focus to the item at `index`, clamped to [0, length). */ + focusItem: (index: number) => void + } + export const [useOtpRoot, provideOtpRoot] = createContext() @@ -126,6 +131,10 @@ const model = defineModel({ default: '' }) + defineEmits<{ + 'update:model-value': [value: string] + }>() + const otp = createOtp({ value: model, // Wrap reactive props as getters — createOtp reads length/pattern via @@ -187,10 +196,7 @@ }, })) - defineExpose({ - /** Move focus to the item at `index`, clamped to [0, length). */ - focusItem, - }) + defineExpose({ focusItem })