Skip to content

feat: add OTP field component#802

Open
rohanchkrabrty wants to merge 3 commits into
mainfrom
worktree-otp-field
Open

feat: add OTP field component#802
rohanchkrabrty wants to merge 3 commits into
mainfrom
worktree-otp-field

Conversation

@rohanchkrabrty
Copy link
Copy Markdown
Contributor

Summary

  • New OTPField component built on Base UI's OTP field with composable OTPField.Input and OTPField.Separator slots
  • Slot styling mirrors the Input component (border/background tokens, hover/focus/invalid states) to match Figma
  • Interactive playground in docs covering length, validationType, mask, disabled, readOnly, autoSubmit
  • Examples for separator, masked, alphanumeric, controlled, complete callback, custom sanitization, and Field wrapper
  • Unit tests covering value entry, masking, disabled/read-only behavior, keyboard navigation, and ref forwarding

@vercel
Copy link
Copy Markdown

vercel Bot commented May 13, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
apsara Ready Ready Preview, Comment May 13, 2026 8:55am

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 13, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This pull request introduces a complete OTPField component to the Raystack UI library. The implementation wraps @base-ui/react OTP field primitives with a styled React component, includes type definitions documenting the public API, and is accompanied by comprehensive test coverage, interactive playground examples, and detailed documentation with multiple code examples demonstrating all supported features and behaviors.

Suggested reviewers

  • rsbh
  • paanSinghCoder
  • Shreyag02
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: add OTP field component' is clear and directly describes the main change—adding a new OTP field component to the library.
Description check ✅ Passed The description is highly detailed and directly related to the changeset, covering component architecture, styling, documentation, examples, and tests.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
apps/www/src/content/docs/components/otp-field/index.mdx (1)

25-25: 💤 Low value

Consider clarifying the length requirement explanation.

The phrase "before all slots hydrate" is slightly confusing. The length prop is required so the component can:

  • Size the input correctly
  • Validate the complete value
  • Detect when all slots are filled

Consider rewording to: "length is required so the field can manage state, validate input, and detect completion."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/www/src/content/docs/components/otp-field/index.mdx` at line 25, Update
the explanatory sentence about the length prop in the OTP field docs: replace
the confusing phrase that mentions "before all slots hydrate" with a clearer
description that references the length prop's responsibilities (e.g., "`length`
is required so the field can manage state, validate input, and detect
completion"). Locate the text in
apps/www/src/content/docs/components/otp-field/index.mdx where the `length` prop
is explained and swap the sentence while keeping the surrounding import/assembly
guidance intact and still mentioning sizing, validation, and completion
detection for the `length` prop.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/www/src/components/playground/otp-field-examples.tsx`:
- Around line 7-13: renderSlots currently computes aria-label totals using
length + offset, which produces incorrect "of" counts for grouped/separated
examples; change the function signature to accept a separate totalLength (e.g.,
renderSlots(length: number, offset = 0, totalLength = length)) and use
totalLength in the aria-label for OTPField.Input (use `Character ${i + 1 +
offset} of ${totalLength}`). Update all calls to renderSlots (including the
separator example and the other instance around lines 58-66) to pass the full
field length as the third argument so screen readers see the correct total.

---

Nitpick comments:
In `@apps/www/src/content/docs/components/otp-field/index.mdx`:
- Line 25: Update the explanatory sentence about the length prop in the OTP
field docs: replace the confusing phrase that mentions "before all slots
hydrate" with a clearer description that references the length prop's
responsibilities (e.g., "`length` is required so the field can manage state,
validate input, and detect completion"). Locate the text in
apps/www/src/content/docs/components/otp-field/index.mdx where the `length` prop
is explained and swap the sentence while keeping the surrounding import/assembly
guidance intact and still mentioning sizing, validation, and completion
detection for the `length` prop.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 90a47a3a-70de-49f4-81d4-bbf6a32a4950

📥 Commits

Reviewing files that changed from the base of the PR and between 055019e and 7b44095.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (11)
  • apps/www/src/components/playground/index.ts
  • apps/www/src/components/playground/otp-field-examples.tsx
  • apps/www/src/content/docs/components/otp-field/demo.ts
  • apps/www/src/content/docs/components/otp-field/index.mdx
  • apps/www/src/content/docs/components/otp-field/props.ts
  • packages/raystack/components/otp-field/__tests__/otp-field.test.tsx
  • packages/raystack/components/otp-field/index.tsx
  • packages/raystack/components/otp-field/otp-field.module.css
  • packages/raystack/components/otp-field/otp-field.tsx
  • packages/raystack/index.tsx
  • packages/raystack/package.json

Comment on lines +7 to +13
const renderSlots = (length: number, offset = 0) =>
Array.from({ length }, (_, i) => (
<OTPField.Input
key={i + offset}
aria-label={`Character ${i + 1 + offset} of ${length + offset}`}
/>
));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix incorrect slot aria-label totals in separator example.

Line 11 generates labels like “Character 1 of 3” for the first group in a 6-character field, which gives incorrect context to screen readers. Pass the total field length separately.

Suggested fix
-const renderSlots = (length: number, offset = 0) =>
+const renderSlots = (length: number, offset = 0, totalLength = length + offset) =>
   Array.from({ length }, (_, i) => (
     <OTPField.Input
       key={i + offset}
-      aria-label={`Character ${i + 1 + offset} of ${length + offset}`}
+      aria-label={`Character ${i + 1 + offset} of ${totalLength}`}
     />
   ));
@@
-          {renderSlots(3)}
+          {renderSlots(3, 0, 6)}

Also applies to: 58-66

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/www/src/components/playground/otp-field-examples.tsx` around lines 7 -
13, renderSlots currently computes aria-label totals using length + offset,
which produces incorrect "of" counts for grouped/separated examples; change the
function signature to accept a separate totalLength (e.g., renderSlots(length:
number, offset = 0, totalLength = length)) and use totalLength in the aria-label
for OTPField.Input (use `Character ${i + 1 + offset} of ${totalLength}`). Update
all calls to renderSlots (including the separator example and the other instance
around lines 58-66) to pass the full field length as the third argument so
screen readers see the correct total.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/raystack/components/slider/__tests__/slider.test.tsx`:
- Around line 140-146: The test "sets aria-valuetext" currently only checks
existence of the input but not the attribute; update the assertion for the
Slider render (the test using render(<Slider value={50} aria-valuetext='50
percent' />), container.querySelector('input[type="range"]') and the slider
variable) to assert that the input has aria-valuetext equal to '50 percent'
(e.g., use expect(slider).toHaveAttribute('aria-valuetext', '50 percent') or
equivalent).
- Around line 79-83: The tests for Slider are inconsistent: "handles single
value" uses synchronous assertions but "handles range values" uses waitFor
unnecessarily; since both are controlled props their attributes should be set on
initial render, so remove the async waitFor from the "handles range values" test
and use the same synchronous pattern as in "handles single value" (query the
input[type=\"range\"] and assert aria-valuenow, aria-valuemin and aria-valuemax
directly). Locate the tests by their descriptions ("handles single value" and
"handles range values") in slider.test.tsx and update the latter to match the
synchronous assertions used in the former.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d82d8cc1-6039-4595-8587-7c0804e306f2

📥 Commits

Reviewing files that changed from the base of the PR and between 7b44095 and 8f2dec5.

📒 Files selected for processing (2)
  • packages/raystack/components/slider/__tests__/slider.test.tsx
  • packages/raystack/index.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/raystack/index.tsx

Comment on lines +79 to 83
it('handles single value', () => {
const { container } = render(<Slider value={50} />);
const slider = container.querySelector('input[type="range"]');
expect(slider).toHaveAttribute('aria-valuenow', '50');
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify if other similar tests in the codebase use sync or async assertions for controlled component values
rg -n -C3 -g '*.test.tsx' -g '*.test.ts' 'value=\{.*\}.*aria-valuenow'

Repository: raystack/apsara

Length of output: 41


🏁 Script executed:

cat -n packages/raystack/components/slider/__tests__/slider.test.tsx | head -150

Repository: raystack/apsara

Length of output: 6813


🏁 Script executed:

# Search for other controlled component tests to understand patterns
rg -n 'aria-valuenow' -A2 -B2 packages/raystack/components/slider/__tests__/

Repository: raystack/apsara

Length of output: 2457


🏁 Script executed:

# Check the Slider component implementation to see if it has async behavior
fd -g 'slider.tsx' -o -g 'slider.ts' packages/raystack/components/slider/ | grep -v test | grep -v '.test'

Repository: raystack/apsara

Length of output: 176


🏁 Script executed:

fd -type f -name 'slider.tsx' -o -name 'slider.ts' | grep -v test | head -5

Repository: raystack/apsara

Length of output: 230


🏁 Script executed:

find packages/raystack/components/slider -type f \( -name '*.tsx' -o -name '*.ts' \) ! -path '*test*' ! -path '*\.test\.*'

Repository: raystack/apsara

Length of output: 152


🏁 Script executed:

cat -n packages/raystack/components/slider/slider.tsx | head -100

Repository: raystack/apsara

Length of output: 3524


Synchronous and asynchronous assertions are inconsistently applied for controlled values.

The "handles single value" test (lines 79-83) uses synchronous assertions, while the "handles range values" test (lines 85-93) uses waitFor. Both tests use controlled props that should set attributes on render immediately. Verify whether the range test actually requires async assertions, or if both should use the same pattern for consistency and to avoid unnecessary delays in test execution.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/raystack/components/slider/__tests__/slider.test.tsx` around lines
79 - 83, The tests for Slider are inconsistent: "handles single value" uses
synchronous assertions but "handles range values" uses waitFor unnecessarily;
since both are controlled props their attributes should be set on initial
render, so remove the async waitFor from the "handles range values" test and use
the same synchronous pattern as in "handles single value" (query the
input[type=\"range\"] and assert aria-valuenow, aria-valuemin and aria-valuemax
directly). Locate the tests by their descriptions ("handles single value" and
"handles range values") in slider.test.tsx and update the latter to match the
synchronous assertions used in the former.

Comment on lines +140 to 146
it('sets aria-valuetext', () => {
const { container } = render(
<Slider value={50} aria-valuetext='50 percent' />
);
const slider = container.querySelector('input[type="range"]');
expect(slider).toBeInTheDocument();
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Critical test coverage regression: aria-valuetext attribute not verified.

The test is named "sets aria-valuetext" but only checks that the slider element exists. It no longer verifies that the aria-valuetext attribute is actually set to the expected value ('50 percent').

🐛 Restore the missing assertion
 it('sets aria-valuetext', () => {
   const { container } = render(
     <Slider value={50} aria-valuetext='50 percent' />
   );
   const slider = container.querySelector('input[type="range"]');
-  expect(slider).toBeInTheDocument();
+  expect(slider).toHaveAttribute('aria-valuetext', '50 percent');
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('sets aria-valuetext', () => {
const { container } = render(
<Slider value={50} aria-valuetext='50 percent' />
);
const slider = container.querySelector('input[type="range"]');
expect(slider).toBeInTheDocument();
});
it('sets aria-valuetext', () => {
const { container } = render(
<Slider value={50} aria-valuetext='50 percent' />
);
const slider = container.querySelector('input[type="range"]');
expect(slider).toHaveAttribute('aria-valuetext', '50 percent');
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/raystack/components/slider/__tests__/slider.test.tsx` around lines
140 - 146, The test "sets aria-valuetext" currently only checks existence of the
input but not the attribute; update the assertion for the Slider render (the
test using render(<Slider value={50} aria-valuetext='50 percent' />),
container.querySelector('input[type="range"]') and the slider variable) to
assert that the input has aria-valuetext equal to '50 percent' (e.g., use
expect(slider).toHaveAttribute('aria-valuetext', '50 percent') or equivalent).

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant