diff --git a/.changeset/feat-otp-component-519.md b/.changeset/feat-otp-component-519.md new file mode 100644 index 000000000..d27cc517f --- /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 000000000..6fa4fdb02 --- /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 000000000..bc4f96622 --- /dev/null +++ b/packages/0/src/components/Otp/OtpItem.vue @@ -0,0 +1,209 @@ +/** + * @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 000000000..cb1dffd9c --- /dev/null +++ b/packages/0/src/components/Otp/OtpRoot.vue @@ -0,0 +1,212 @@ +/** + * @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 000000000..9cf4443c0 --- /dev/null +++ b/packages/0/src/components/Otp/index.test.ts @@ -0,0 +1,707 @@ +import { describe, expect, it, vi } 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 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() + + 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 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 }) + 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 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() + + 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 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 }) + await wait() + + const dataTransfer = { getData: () => '1234' } + await itemEls()[0]!.trigger('paste', { clipboardData: dataTransfer }) + await wait() + + expect(model.value).toBe('1234') + }) + }) + + 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 } }) + await wait() + + await itemEls()[0]!.setValue('4') + await wait() + + expect(model.value).toBe('') + }) + }) + + 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 } }) + 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 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) + }) + }) + + 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 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 => { + 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 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({ + 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('') + }) + }) + + 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) + }) + }) + + 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') + }) + }) +}) diff --git a/packages/0/src/components/Otp/index.ts b/packages/0/src/components/Otp/index.ts new file mode 100644 index 000000000..5168b69c9 --- /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, OtpRootExpose, 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 6662d2ba7..1038647d9 100644 --- a/packages/0/src/components/a11y.browser.test.ts +++ b/packages/0/src/components/a11y.browser.test.ts @@ -31,6 +31,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' @@ -136,6 +137,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 000000000..6436714ed --- /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 3a820dd8b..7aea5deef 100644 --- a/packages/0/src/components/index.ts +++ b/packages/0/src/components/index.ts @@ -17,6 +17,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 053cbda18..7e6c843e8 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 c417e785d..ead808483 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": null, + "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 817e3c126..bc407834f 100644 --- a/packages/0/src/surface.test.ts +++ b/packages/0/src/surface.test.ts @@ -40,7 +40,7 @@ const COMPOSABLES = [ ] const COMPONENTS = [ - 'Alert', 'AlertDescription', 'AlertDialog', 'AlertDialogAction', 'AlertDialogActivator', 'AlertDialogCancel', 'AlertDialogClose', 'AlertDialogContent', 'AlertDialogDescription', 'AlertDialogRoot', 'AlertDialogTitle', 'AlertRoot', 'AlertTitle', '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', + 'Alert', 'AlertDescription', 'AlertDialog', 'AlertDialogAction', 'AlertDialogActivator', 'AlertDialogCancel', 'AlertDialogClose', 'AlertDialogContent', 'AlertDialogDescription', 'AlertDialogRoot', 'AlertDialogTitle', 'AlertRoot', 'AlertTitle', '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 = [