Skip to content

Commit f0145aa

Browse files
committed
fix(VOtpInput): move focus correctly in RTL mode
1 parent 02f5f81 commit f0145aa

3 files changed

Lines changed: 69 additions & 4 deletions

File tree

packages/vuetify/src/components/VOtpInput/VOtpInput.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -199,10 +199,13 @@ export const VOtpInput = genericComponent<VOtpInputSlots>()({
199199
}
200200

201201
function onKeydown (e: KeyboardEvent) {
202-
if (e.shiftKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
203-
// Extend with our own anchor so the originally-selected slot stays included.
202+
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
204203
const direction = (e.key === 'ArrowLeft' ? -1 : 1) * (isRtl.value ? -1 : 1) as -1 | 1
205-
if (otp.extendSelection(direction)) {
204+
const moved = e.shiftKey
205+
? otp.extendSelection(direction)
206+
: otp.moveCaret(direction)
207+
208+
if (moved) {
206209
e.preventDefault()
207210
syncDOM()
208211
}
@@ -351,7 +354,6 @@ export const VOtpInput = genericComponent<VOtpInputSlots>()({
351354
<div
352355
class="v-otp-input__content"
353356
style={[dimensionStyles.value]}
354-
dir={ isRtl.value ? 'rtl' : 'ltr' }
355357
>
356358
{ slots.fields ? slots.fields() : props.merged
357359
? (

packages/vuetify/src/components/VOtpInput/__tests__/VOtpInput.spec.browser.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,58 @@ describe('VOtpInput', () => {
102102
expect(getActiveSlotIndex()).toBe(2)
103103
})
104104

105+
it('navigates using arrows it in rtl', async () => {
106+
render(() => (<VOtpInput length={ 6 } />), null, {
107+
locale: { rtl: { en: true } },
108+
})
109+
110+
await focusInput()
111+
await userEvent.keyboard('1234')
112+
expect(getActiveSlotIndex()).toBe(4)
113+
114+
for (const expected of [3, 2, 1, 0]) {
115+
await userEvent.keyboard('{ArrowRight}')
116+
expect(getActiveSlotIndex()).toBe(expected)
117+
}
118+
119+
await userEvent.keyboard('{ArrowRight}') // overshoot, no-op
120+
expect(getActiveSlotIndex()).toBe(0)
121+
122+
for (const expected of [1, 2]) {
123+
await userEvent.keyboard('{ArrowLeft}')
124+
expect(getActiveSlotIndex()).toBe(expected)
125+
}
126+
127+
await userEvent.keyboard('{Delete}')
128+
expect(getInput().value).toBe('124')
129+
expect(getActiveSlotIndex()).toBe(2)
130+
})
131+
132+
it('navigates using arrows it in ltr', async () => {
133+
render(() => (<VOtpInput length={ 6 } />))
134+
135+
await focusInput()
136+
await userEvent.keyboard('1234')
137+
expect(getActiveSlotIndex()).toBe(4)
138+
139+
for (const expected of [3, 2, 1, 0]) {
140+
await userEvent.keyboard('{ArrowLeft}')
141+
expect(getActiveSlotIndex()).toBe(expected)
142+
}
143+
144+
await userEvent.keyboard('{ArrowLeft}') // overshoot, no-op
145+
expect(getActiveSlotIndex()).toBe(0)
146+
147+
for (const expected of [1, 2]) {
148+
await userEvent.keyboard('{ArrowRight}')
149+
expect(getActiveSlotIndex()).toBe(expected)
150+
}
151+
152+
await userEvent.keyboard('{Delete}')
153+
expect(getInput().value).toBe('124')
154+
expect(getActiveSlotIndex()).toBe(2)
155+
})
156+
105157
it('removes value and goes back when using backspace', async () => {
106158
render(() => (<VOtpInput />))
107159
const input = getInput()

packages/vuetify/src/components/VOtpInput/useOtpInput.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export interface OtpInputContext {
7676
clearSelection: () => void
7777
selectAtEnd: () => OtpSelection
7878
selectSlot: (index: number) => OtpSelection
79+
moveCaret: (direction: -1 | 1) => OtpSelection | null
7980
extendSelection: (direction: -1 | 1) => OtpSelection | null
8081

8182
startComposition: () => void
@@ -306,6 +307,15 @@ export function useOtpInput (options: OtpInputOptions): OtpInputContext {
306307
return next
307308
}
308309

310+
function moveCaret (direction: -1 | 1): OtpSelection | null {
311+
const current = value.value
312+
if (graphemes(current).length === 0) return null
313+
314+
const currentSelection = selection.value
315+
const currentG = currentSelection ? codeUnitsToGraphemeIndex(current, currentSelection.start) : 0
316+
return selectSlot(Math.max(0, currentG + direction))
317+
}
318+
309319
function extendSelection (direction: -1 | 1): OtpSelection | null {
310320
const current = value.value
311321
const valueG = graphemes(current).length
@@ -465,6 +475,7 @@ export function useOtpInput (options: OtpInputOptions): OtpInputContext {
465475
clearSelection,
466476
selectAtEnd,
467477
selectSlot,
478+
moveCaret,
468479
extendSelection,
469480

470481
startComposition,

0 commit comments

Comments
 (0)