[otp field] Fix vertical arrow slot navigation#4844
Conversation
commit: |
Bundle size
PerformanceTotal duration: 934.37 ms ▼-352.64 ms(-27.4%) | Renders: 50 (+0) | Paint: 1,391.57 ms ▼-570.29 ms(-29.1%)
…and 3 more (+4 within noise) — details Check out the code infra dashboard for more information about this PR. |
✅ Deploy Preview for base-ui ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
8a87172 to
cc987e6
Compare
cc987e6 to
0f3d030
Compare
0f3d030 to
5f44aa7
Compare
flaviendelangle
left a comment
There was a problem hiding this comment.
PR Review Summary — #4844 [otp field] Fix vertical arrow slot navigation
Author: atomiks • Base: master → Head: codex/fix-otp-field-selection • Diff: +63 / −6 across 2 files
The PR maps ArrowUp/ArrowDown in OTPField.Input to Home/End-like slot navigation (with ArrowDown keeping focus in place on an empty slot), and fixes a latent re-.select() bug when the same character is retyped into the final slot.
Critical Issues
None blocking. Both review agents converged — no correctness, security, or merge-blocker concerns.
Important Issues
-
[tests] Lost Home/End readonly coverage —
packages/react/src/otp-field/input/OTPFieldInput.test.tsx:306-312. The existing readonly navigation test was modified to swapHome/Endassertions forArrowUp/ArrowDown, removing readonly coverage for the original keys. Either restore the Home/End assertions alongside the new ones, or add a dedicated arrow-key readonly test. A future regression breaking readonly Home/End would now go unnoticed. -
[tests] No ArrowDown boundary case (already on last filled slot) —
packages/react/src/otp-field/input/OTPFieldInput.test.tsx:199-211. The parametrized test only exercises ArrowDown from a non-boundary index (inputs[1]ofdefaultValue="1234", lastFilledIndex=3). The case where current index already equalslastFilledIndexis exactly where the newslotValue !== ''guard interacts — focus should stay,keyDownshould still returnfalse. Without it, a refactor that flipped the guard polarity could pass silently.
Suggestions
-
[code-review]
lastFilledIndexis a misleading name —packages/react/src/otp-field/input/OTPFieldInput.tsx:199.Math.max(value.length - 1, 0)returns0whenvalue === '', which isn't actually a "filled" index. Current callers happen to be safe (focusing slot 0 when empty is correct; the newslotValue !== ''guard on ArrowDown short-circuits before it matters), but the name implies a postcondition the value doesn't guarantee. ConsiderendTargetIndex. -
[code-review] ArrowDown on an empty slot still calls
stopEvent—packages/react/src/otp-field/input/OTPFieldInput.tsx:218-223. Functionally fine fortype=text|password(browsers have no default ArrowDown behavior to suppress), but if the inputtypeever changes, suppressing it silently could mask issues. Either movestopEventinside the conditional, or leave a one-line WHY. -
[code-review] Modifier-semantics asymmetry vs. ArrowLeft/Right —
packages/react/src/otp-field/input/OTPFieldInput.tsx:200-212vs218-223. ArrowLeft/Right only jump to boundary withCtrl/Meta; ArrowUp/Down jump unconditionally. Not wrong (WAI-ARIA APG doesn't standardize an OTP keyboard model), but worth confirming with the design system that the divergence is intentional — some macOS users expectCmd+Arrowsemantics across all four directions. -
[tests] No coverage for ArrowUp/ArrowDown in
disabledmode — the early-return atOTPFieldInput.tsx:194now gates the new keys too. AdefaultValue="12" disabled+ ArrowUp test would harden the gate; no existing test covers disabled keyboard behavior. -
[tests]
stopPropagationnot directly asserted —OTPFieldInput.test.tsx:215-220assertspreventDefaultviafireEvent.keyDown(...) === false.stopEventalso callsstopPropagation, which the return value doesn't capture. Minor — both calls live in the same helper.
Strengths
-
The re-
.select()fix is the standout. ReplacingfocusInput(Math.min(length - 1, index + 1))withif (index < length - 1) focusInput(index + 1)atpackages/react/src/otp-field/input/OTPFieldInput.tsx:258-262correctly avoids the spurious.select()call (root invokestarget.select()unconditionally) when the user retypes the same character on the last slot. The test atOTPFieldInput.test.tsx:222-241usesvi.spyOn(lastInput, 'select')— a precise behavioral assertion that would catch any regression. -
ArrowDown-on-empty-slot semantics match the user mental model. When the focus is already past the filled region, the natural "end" is the current position. Captured cleanly by the test at
OTPFieldInput.test.tsx:213-221. -
Tests follow project conventions. Uses
fireEvent.keyDownreturn-value to assertpreventDefault, Vitest APIs only (vi.spyOn,vi.fn— no Chai/Sinon chains),screen.getAllByRolewith explicit<HTMLInputElement>typing, andact-wrapped focus changes. Aligns with AGENTS.md testing guidelines. -
No CLAUDE.md / AGENTS.md violations — no
as any, no new hooks (souseTimeout/useStableCallback/useIsoLayoutEffectN/A), nodocument/windowlookups added. -
Small, focused diff that fixes two real bugs (missing ArrowUp/Down navigation + re-
.select()on the last slot) without scope creep.
Recommended Action
-
Address before merge (low-effort):
- Restore Home/End readonly coverage at
OTPFieldInput.test.tsx:306-312(or add a sibling test for ArrowUp/Down in readonly). - Add the ArrowDown-on-last-filled-slot boundary test case.
- Restore Home/End readonly coverage at
-
Consider (cheap polish):
- Rename
lastFilledIndex→endTargetIndexatOTPFieldInput.tsx:199. - Confirm with design system that ArrowUp/Down unconditional boundary-jump (vs ArrowLeft/Right requiring Ctrl/Meta) is intentional.
- Rename
-
Follow-up (optional):
- Disabled-mode coverage for the new keys.
Pressing ArrowUp or ArrowDown in an OTP slot could collapse the slot selection, so a later typed character could be blocked by the one-character input length.
Root cause
Vertical arrows fell through to native text-input caret handling instead of being handled as OTP field navigation.
Changes