Skip to content

Conversation

@hasegawa-101
Copy link
Contributor

@hasegawa-101 hasegawa-101 commented Nov 22, 2025

Closes #5136

📝 Description

Add isRealTimeFormat prop to enable real-time number formatting while typing.

⛳️ Current behavior (updates)

NumberInput only formats numbers on blur. Users cannot type more than 4 digits with useGrouping: true because the formatted comma interferes with input.

🚀 New behavior

  • Add isRealTimeFormat prop (default: false)
  • When enabled: formats numbers as you type (12345 → 12,345)

💣 Is this a breaking change (Yes/No):

No - Default behavior unchanged. Opt-in only.

📝 Additional Information

Summary by CodeRabbit

  • New Features

    • Real-time number formatting: Numbers are now automatically formatted as you type with full locale-aware validation. Customize decimal style, digit grouping, and other formatting behavior. Use the new isRealTimeFormat option to enable or disable real-time formatting, with additional control via formatOptions parameter.
  • Chores

    • Updated internationalization dependencies for improved number handling

✏️ Tip: You can customize this high-level summary in your review settings.

@changeset-bot
Copy link

changeset-bot bot commented Nov 22, 2025

⚠️ No Changeset found

Latest commit: 4a6a03f

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel
Copy link

vercel bot commented Nov 22, 2025

@hasegawa-101 is attempting to deploy a commit to the HeroUI Inc Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 22, 2025

Walkthrough

This PR adds real-time number formatting to the NumberInput component by introducing a new isRealTimeFormat prop and leveraging the @internationalized/number library for NumberParser and formatting. The implementation intercepts input before it reaches the DOM, validates partial numbers, and displays formatted output as users type.

Changes

Cohort / File(s) Summary
Dependency Addition
packages/components/number-input/package.json
Added @internationalized/number (v3.6.5) as a runtime dependency to support number parsing and formatting.
Core Real-Time Formatting
packages/components/number-input/src/use-number-input.ts
Introduced isRealTimeFormat prop, NumberParser and Intl.NumberFormat instances, handleBeforeInput handler for real-time validation and formatting, shouldFormat logic to conditionally enable formatting, and memoized formatter/parser instances tied to locale and options.
Story Addition
packages/components/number-input/stories/number-input.stories.tsx
Added WithRealTimeFormat story demonstrating the new feature with decimal style and grouping enabled.

Sequence Diagram

sequenceDiagram
    participant User
    participant Input as Input Element
    participant Handler as handleBeforeInput
    participant Parser as NumberParser
    participant Formatter as Intl.NumberFormat
    participant State as Component State

    User->>Input: Types character
    Input->>Handler: Fires beforeinput event<br/>(isRealTimeFormat enabled)
    Handler->>Parser: Validate partial input
    alt Valid partial number
        Parser-->>Handler: Parsing succeeds
        Handler->>Formatter: Format validated number
        Formatter-->>Handler: Formatted display value
        Handler->>State: Update internal & numeric value
        Handler->>Input: preventDefault + set formatted value
        State->>Input: Emit onChange with formatted value
    else Invalid input
        Parser-->>Handler: Parsing fails
        Handler->>Input: preventDefault (block invalid char)
    end
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

  • Complexity considerations:

    • use-number-input.ts introduces new logic for real-time input interception with NumberParser/Formatter integration
    • Conditional onBeforeInput attachment based on shouldFormat logic requires careful validation
    • Memoization of formatter/parser instances and dependency management need verification
    • New prop (isRealTimeFormat) and its integration into existing input flow
  • Areas requiring attention:

    • handleBeforeInput implementation: validate that partial number validation and state updates don't cause unintended side effects
    • Dependency array updates in memoization hooks (numberFormatter, numberParser, handleBeforeInput)
    • Interaction between shouldFormat logic and existing input handlers
    • Backward compatibility: ensure changes don't break existing behavior when isRealTimeFormat is false/undefined

Possibly Related PRs

Suggested Reviewers

  • jrgarciadev

Pre-merge checks and finishing touches

❌ 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%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main feature addition: introducing the isRealTimeFormat prop to the number-input component.
Linked Issues check ✅ Passed The changes implement real-time number formatting via isRealTimeFormat prop with NumberParser and onBeforeInput handling, addressing issue #5136's requirement for real-time formatting without digit limits.
Out of Scope Changes check ✅ Passed All changes are focused on implementing real-time formatting for NumberInput through the new isRealTimeFormat prop and supporting infrastructure.
Description check ✅ Passed The PR description follows the required template with all key sections completed: issue reference (#5136), clear description of the new prop, current behavior explanation, new behavior details, and confirmation it is not a breaking change.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

@pkg-pr-new
Copy link

pkg-pr-new bot commented Nov 22, 2025

Open in StackBlitz

@heroui/accordion

npm i https://pkg.pr.new/@heroui/accordion@5926

@heroui/alert

npm i https://pkg.pr.new/@heroui/alert@5926

@heroui/autocomplete

npm i https://pkg.pr.new/@heroui/autocomplete@5926

@heroui/avatar

npm i https://pkg.pr.new/@heroui/avatar@5926

@heroui/badge

npm i https://pkg.pr.new/@heroui/badge@5926

@heroui/breadcrumbs

npm i https://pkg.pr.new/@heroui/breadcrumbs@5926

@heroui/button

npm i https://pkg.pr.new/@heroui/button@5926

@heroui/calendar

npm i https://pkg.pr.new/@heroui/calendar@5926

@heroui/card

npm i https://pkg.pr.new/@heroui/card@5926

@heroui/checkbox

npm i https://pkg.pr.new/@heroui/checkbox@5926

@heroui/chip

npm i https://pkg.pr.new/@heroui/chip@5926

@heroui/code

npm i https://pkg.pr.new/@heroui/code@5926

@heroui/date-input

npm i https://pkg.pr.new/@heroui/date-input@5926

@heroui/date-picker

npm i https://pkg.pr.new/@heroui/date-picker@5926

@heroui/divider

npm i https://pkg.pr.new/@heroui/divider@5926

@heroui/drawer

npm i https://pkg.pr.new/@heroui/drawer@5926

@heroui/dropdown

npm i https://pkg.pr.new/@heroui/dropdown@5926

@heroui/form

npm i https://pkg.pr.new/@heroui/form@5926

@heroui/image

npm i https://pkg.pr.new/@heroui/image@5926

@heroui/input

npm i https://pkg.pr.new/@heroui/input@5926

@heroui/input-otp

npm i https://pkg.pr.new/@heroui/input-otp@5926

@heroui/kbd

npm i https://pkg.pr.new/@heroui/kbd@5926

@heroui/link

npm i https://pkg.pr.new/@heroui/link@5926

@heroui/listbox

npm i https://pkg.pr.new/@heroui/listbox@5926

@heroui/menu

npm i https://pkg.pr.new/@heroui/menu@5926

@heroui/modal

npm i https://pkg.pr.new/@heroui/modal@5926

@heroui/navbar

npm i https://pkg.pr.new/@heroui/navbar@5926

@heroui/number-input

npm i https://pkg.pr.new/@heroui/number-input@5926

@heroui/pagination

npm i https://pkg.pr.new/@heroui/pagination@5926

@heroui/popover

npm i https://pkg.pr.new/@heroui/popover@5926

@heroui/progress

npm i https://pkg.pr.new/@heroui/progress@5926

@heroui/radio

npm i https://pkg.pr.new/@heroui/radio@5926

@heroui/ripple

npm i https://pkg.pr.new/@heroui/ripple@5926

@heroui/scroll-shadow

npm i https://pkg.pr.new/@heroui/scroll-shadow@5926

@heroui/select

npm i https://pkg.pr.new/@heroui/select@5926

@heroui/skeleton

npm i https://pkg.pr.new/@heroui/skeleton@5926

@heroui/slider

npm i https://pkg.pr.new/@heroui/slider@5926

@heroui/snippet

npm i https://pkg.pr.new/@heroui/snippet@5926

@heroui/spacer

npm i https://pkg.pr.new/@heroui/spacer@5926

@heroui/spinner

npm i https://pkg.pr.new/@heroui/spinner@5926

@heroui/switch

npm i https://pkg.pr.new/@heroui/switch@5926

@heroui/table

npm i https://pkg.pr.new/@heroui/table@5926

@heroui/tabs

npm i https://pkg.pr.new/@heroui/tabs@5926

@heroui/toast

npm i https://pkg.pr.new/@heroui/toast@5926

@heroui/tooltip

npm i https://pkg.pr.new/@heroui/tooltip@5926

@heroui/user

npm i https://pkg.pr.new/@heroui/user@5926

@heroui/react

npm i https://pkg.pr.new/@heroui/react@5926

@heroui/system

npm i https://pkg.pr.new/@heroui/system@5926

@heroui/system-rsc

npm i https://pkg.pr.new/@heroui/system-rsc@5926

@heroui/theme

npm i https://pkg.pr.new/@heroui/theme@5926

@heroui/use-aria-accordion

npm i https://pkg.pr.new/@heroui/use-aria-accordion@5926

@heroui/use-aria-accordion-item

npm i https://pkg.pr.new/@heroui/use-aria-accordion-item@5926

@heroui/use-aria-button

npm i https://pkg.pr.new/@heroui/use-aria-button@5926

@heroui/use-aria-link

npm i https://pkg.pr.new/@heroui/use-aria-link@5926

@heroui/use-aria-modal-overlay

npm i https://pkg.pr.new/@heroui/use-aria-modal-overlay@5926

@heroui/use-aria-multiselect

npm i https://pkg.pr.new/@heroui/use-aria-multiselect@5926

@heroui/use-aria-overlay

npm i https://pkg.pr.new/@heroui/use-aria-overlay@5926

@heroui/use-callback-ref

npm i https://pkg.pr.new/@heroui/use-callback-ref@5926

@heroui/use-clipboard

npm i https://pkg.pr.new/@heroui/use-clipboard@5926

@heroui/use-data-scroll-overflow

npm i https://pkg.pr.new/@heroui/use-data-scroll-overflow@5926

@heroui/use-disclosure

npm i https://pkg.pr.new/@heroui/use-disclosure@5926

@heroui/use-draggable

npm i https://pkg.pr.new/@heroui/use-draggable@5926

@heroui/use-form-reset

npm i https://pkg.pr.new/@heroui/use-form-reset@5926

@heroui/use-image

npm i https://pkg.pr.new/@heroui/use-image@5926

@heroui/use-infinite-scroll

npm i https://pkg.pr.new/@heroui/use-infinite-scroll@5926

@heroui/use-intersection-observer

npm i https://pkg.pr.new/@heroui/use-intersection-observer@5926

@heroui/use-is-mobile

npm i https://pkg.pr.new/@heroui/use-is-mobile@5926

@heroui/use-is-mounted

npm i https://pkg.pr.new/@heroui/use-is-mounted@5926

@heroui/use-measure

npm i https://pkg.pr.new/@heroui/use-measure@5926

@heroui/use-pagination

npm i https://pkg.pr.new/@heroui/use-pagination@5926

@heroui/use-real-shape

npm i https://pkg.pr.new/@heroui/use-real-shape@5926

@heroui/use-ref-state

npm i https://pkg.pr.new/@heroui/use-ref-state@5926

@heroui/use-resize

npm i https://pkg.pr.new/@heroui/use-resize@5926

@heroui/use-safe-layout-effect

npm i https://pkg.pr.new/@heroui/use-safe-layout-effect@5926

@heroui/use-scroll-position

npm i https://pkg.pr.new/@heroui/use-scroll-position@5926

@heroui/use-ssr

npm i https://pkg.pr.new/@heroui/use-ssr@5926

@heroui/use-theme

npm i https://pkg.pr.new/@heroui/use-theme@5926

@heroui/use-update-effect

npm i https://pkg.pr.new/@heroui/use-update-effect@5926

@heroui/use-viewport-size

npm i https://pkg.pr.new/@heroui/use-viewport-size@5926

@heroui/aria-utils

npm i https://pkg.pr.new/@heroui/aria-utils@5926

@heroui/dom-animation

npm i https://pkg.pr.new/@heroui/dom-animation@5926

@heroui/framer-utils

npm i https://pkg.pr.new/@heroui/framer-utils@5926

@heroui/react-rsc-utils

npm i https://pkg.pr.new/@heroui/react-rsc-utils@5926

@heroui/react-utils

npm i https://pkg.pr.new/@heroui/react-utils@5926

@heroui/shared-icons

npm i https://pkg.pr.new/@heroui/shared-icons@5926

@heroui/shared-utils

npm i https://pkg.pr.new/@heroui/shared-utils@5926

@heroui/stories-utils

npm i https://pkg.pr.new/@heroui/stories-utils@5926

@heroui/test-utils

npm i https://pkg.pr.new/@heroui/test-utils@5926

commit: 4a6a03f

@hasegawa-101 hasegawa-101 marked this pull request as ready for review November 22, 2025 13:48
Copy link
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: 3

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5d9a05b and 4a6a03f.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (3)
  • packages/components/number-input/package.json (1 hunks)
  • packages/components/number-input/src/use-number-input.ts (5 hunks)
  • packages/components/number-input/stories/number-input.stories.tsx (1 hunks)
🔇 Additional comments (8)
packages/components/number-input/stories/number-input.stories.tsx (1)

534-547: LGTM! Good demonstration of the new feature.

The story effectively demonstrates real-time formatting with appropriate format options and uses a controlled component to showcase the value updates.

packages/components/number-input/src/use-number-input.ts (6)

17-17: LGTM! Appropriate import for number parsing.

The NumberParser from @internationalized/number is correctly imported to support locale-aware number parsing.


83-88: LGTM! Clear prop definition with good documentation.

The isRealTimeFormat prop is well-documented and defaults to false, maintaining backward compatibility with existing behavior.


314-320: LGTM! Proper memoization of formatters.

The numberFormatter and numberParser are correctly memoized with appropriate dependencies to avoid unnecessary recreations while ensuring they update when locale or format options change.


461-462: Good conditional attachment pattern.

The conditional attachment of onBeforeInput only when shouldFormat is true is a good practice. However, this depends on fixing the shouldFormat logic flagged earlier.


469-484: LGTM! Dependency array correctly updated.

The dependency array properly includes shouldFormat and handleBeforeInput, ensuring the props getter updates when these values change.


332-376: Review comment is factually incorrect on both concerns.

  1. Deletion handling is implemented: handleKeyDown explicitly handles Backspace (lines 271–303) with e.preventDefault() and custom logic for grouping separators. Deletion operations are not ignored.

  2. Validation input type is correct: React Aria's useNumberFieldState.validate expects a string input, so calling state.validate(formattedValue) with the formatted string is the correct approach, not an error.

The code follows React Aria's design patterns appropriately.

Likely an incorrect or invalid review comment.

packages/components/number-input/package.json (1)

51-51: No issues found — dependency version is current and secure.

Verification confirms that @internationalized/[email protected] is the latest stable version with no known security vulnerabilities.

Comment on lines +322 to +330
const shouldFormat = useMemo(() => {
// Return false if isRealTimeFormat is not enabled (React Aria default)
if (!originalProps.isRealTimeFormat) return false;

// Only check useGrouping if isRealTimeFormat is true
const resolved = numberFormatter.resolvedOptions();

return resolved.useGrouping !== false;
}, [originalProps.isRealTimeFormat, numberFormatter]);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Overly restrictive condition for enabling real-time formatting.

The current logic only enables real-time formatting when useGrouping !== false. However, users may want real-time formatting for other scenarios like currency (style: "currency"), percentages (style: "percent"), or custom decimal places, even without grouping separators.

Consider simplifying to enable formatting whenever isRealTimeFormat is true:

 const shouldFormat = useMemo(() => {
-  // Return false if isRealTimeFormat is not enabled (React Aria default)
-  if (!originalProps.isRealTimeFormat) return false;
-
-  // Only check useGrouping if isRealTimeFormat is true
-  const resolved = numberFormatter.resolvedOptions();
-
-  return resolved.useGrouping !== false;
+  return !!originalProps.isRealTimeFormat;
 }, [originalProps.isRealTimeFormat, numberFormatter]);
📝 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
const shouldFormat = useMemo(() => {
// Return false if isRealTimeFormat is not enabled (React Aria default)
if (!originalProps.isRealTimeFormat) return false;
// Only check useGrouping if isRealTimeFormat is true
const resolved = numberFormatter.resolvedOptions();
return resolved.useGrouping !== false;
}, [originalProps.isRealTimeFormat, numberFormatter]);
const shouldFormat = useMemo(() => {
return !!originalProps.isRealTimeFormat;
}, [originalProps.isRealTimeFormat, numberFormatter]);
Suggested change
const shouldFormat = useMemo(() => {
// Return false if isRealTimeFormat is not enabled (React Aria default)
if (!originalProps.isRealTimeFormat) return false;
// Only check useGrouping if isRealTimeFormat is true
const resolved = numberFormatter.resolvedOptions();
return resolved.useGrouping !== false;
}, [originalProps.isRealTimeFormat, numberFormatter]);
const shouldFormat = useMemo(() => {
return !!originalProps.isRealTimeFormat;
}, [originalProps.isRealTimeFormat]);
🤖 Prompt for AI Agents
In packages/components/number-input/src/use-number-input.ts around lines 322 to
330, the memoized shouldFormat is too restrictive because it only enables
real-time formatting when resolved.useGrouping !== false; instead, change it to
enable formatting whenever originalProps.isRealTimeFormat is truthy. Concretely:
remove the numberFormatter.resolvedOptions() check and have the memo return
Boolean(originalProps.isRealTimeFormat). Update the hook dependency array to
only include originalProps.isRealTimeFormat (remove numberFormatter) since it is
no longer used in this memo.

Comment on lines +332 to +376
const handleBeforeInput = useCallback(
(e: React.FormEvent<HTMLInputElement> & {data: string | null}) => {
if (!e.data) return;

const input = domRef.current;

if (!input) return;

const {value, selectionStart, selectionEnd} = input;
const nextValue =
value.slice(0, selectionStart ?? 0) + e.data + value.slice(selectionEnd ?? 0);

// Use React Aria's NumberParser for validation and parsing
// This handles full-width numbers and locale-specific symbols
if (!numberParser.isValidPartialNumber(nextValue)) {
e.preventDefault();

return;
}

const parsedValue = numberParser.parse(nextValue);

if (isNaN(parsedValue)) return;

e.preventDefault();

const formattedValue = numberFormatter.format(parsedValue);

// Call validate like React Aria does
if (!state.validate(formattedValue)) {
return;
}

state.setInputValue(formattedValue);
state.setNumberValue(parsedValue);

if (onChange) {
onChange({
target: {value: formattedValue},
currentTarget: {value: formattedValue},
} as React.ChangeEvent<HTMLInputElement>);
}
},
[numberParser, numberFormatter, state, domRef, onChange],
);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Multiple concerns with the handleBeforeInput implementation.

  1. Line 354 - Missing preventDefault for NaN case: When parsedValue is NaN, the function returns without calling e.preventDefault(), allowing the default input behavior to proceed. This can result in invalid characters being entered.

  2. No cursor position management: After formatting, the cursor position is not adjusted. For example, if a user types "1234" and the formatter adds a separator to make "1,234", the cursor position will be incorrect after each keystroke.

  3. Browser compatibility: The onBeforeInput event is not supported in older versions of Firefox and some other browsers. This could cause the feature to silently fail for some users.

  4. Limited input method coverage: onBeforeInput doesn't handle paste operations, cut operations, or IME (Input Method Editor) input used for languages like Chinese and Japanese.

Recommendations:

  1. Add e.preventDefault() when parsedValue is NaN:
 const parsedValue = numberParser.parse(nextValue);
 
 if (isNaN(parsedValue)) {
+  e.preventDefault();
   return;
 }
  1. Manage cursor position after formatting (this is complex and may require tracking the position relative to digits rather than characters).

  2. Consider using onInput or onChange instead, or add fallback handling for browsers without onBeforeInput support.

  3. Handle paste events separately with onPaste handler.

📝 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
const handleBeforeInput = useCallback(
(e: React.FormEvent<HTMLInputElement> & {data: string | null}) => {
if (!e.data) return;
const input = domRef.current;
if (!input) return;
const {value, selectionStart, selectionEnd} = input;
const nextValue =
value.slice(0, selectionStart ?? 0) + e.data + value.slice(selectionEnd ?? 0);
// Use React Aria's NumberParser for validation and parsing
// This handles full-width numbers and locale-specific symbols
if (!numberParser.isValidPartialNumber(nextValue)) {
e.preventDefault();
return;
}
const parsedValue = numberParser.parse(nextValue);
if (isNaN(parsedValue)) return;
e.preventDefault();
const formattedValue = numberFormatter.format(parsedValue);
// Call validate like React Aria does
if (!state.validate(formattedValue)) {
return;
}
state.setInputValue(formattedValue);
state.setNumberValue(parsedValue);
if (onChange) {
onChange({
target: {value: formattedValue},
currentTarget: {value: formattedValue},
} as React.ChangeEvent<HTMLInputElement>);
}
},
[numberParser, numberFormatter, state, domRef, onChange],
);
const handleBeforeInput = useCallback(
(e: React.FormEvent<HTMLInputElement> & {data: string | null}) => {
if (!e.data) return;
const input = domRef.current;
if (!input) return;
const {value, selectionStart, selectionEnd} = input;
const nextValue =
value.slice(0, selectionStart ?? 0) + e.data + value.slice(selectionEnd ?? 0);
// Use React Aria's NumberParser for validation and parsing
// This handles full-width numbers and locale-specific symbols
if (!numberParser.isValidPartialNumber(nextValue)) {
e.preventDefault();
return;
}
const parsedValue = numberParser.parse(nextValue);
if (isNaN(parsedValue)) {
e.preventDefault();
return;
}
e.preventDefault();
const formattedValue = numberFormatter.format(parsedValue);
// Call validate like React Aria does
if (!state.validate(formattedValue)) {
return;
}
state.setInputValue(formattedValue);
state.setNumberValue(parsedValue);
if (onChange) {
onChange({
target: {value: formattedValue},
currentTarget: {value: formattedValue},
} as React.ChangeEvent<HTMLInputElement>);
}
},
[numberParser, numberFormatter, state, domRef, onChange],
);
🤖 Prompt for AI Agents
In packages/components/number-input/src/use-number-input.ts around lines 332 to
376, the beforeInput handler fails to prevent default on NaN (allowing invalid
characters), does not restore cursor position after formatter changes, and lacks
fallbacks for browsers/IMEs/paste/cut; fix by (1) calling e.preventDefault()
immediately when parsedValue is NaN so the input is blocked; (2) compute and
restore the caret based on digit-relative position (map cursor to index among
numeric characters before formatting, apply formatter, then set
selectionStart/End on the input to the equivalent digit-relative position in the
formatted string) after state.setInputValue so typing keeps expected cursor
placement; (3) add fallbacks/augmentation: handle onPaste and onCut to sanitize
pasted/cut content and call the same parse/format logic, and add an
onInput/onChange fallback branch for browsers lacking onBeforeInput or IME
composition (also respect compositionstart/compositionend to avoid interfering
with IME); ensure domRef is used to set selection and that all new handlers call
e.preventDefault where appropriate.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

Consider IME composition and accessibility implications.

The onBeforeInput handler intercepts and prevents default input behavior, which can interfere with:

  1. IME (Input Method Editor) composition: Users typing in languages like Japanese, Chinese, or Korean use composition events. Intercepting onBeforeInput may break the composition process.

  2. Accessibility tools: Screen readers and other assistive technologies may rely on standard input events.

Consider checking for composition state before intercepting:

const handleBeforeInput = useCallback(
  (e: React.FormEvent<HTMLInputElement> & {data: string | null}) => {
    if (!e.data) return;
    
    // Skip if IME composition is in progress
    const input = domRef.current;
    if (!input) return;
    
    // Check if composition is active (for IME input)
    if ((e as any).isComposing) {
      return;
    }
    
    // ... rest of the logic
  },
  [/* dependencies */]
);

Additionally, search for how React Aria handles IME in their number field:


🌐 Web query:

How does React Aria's useNumberField handle IME composition events?

💡 Result:

Short answer: React Aria's useNumberField supports IME by treating composition as composed input — it listens for compositionstart/compositionend and defers parsing/validation until composition finishes so the IME intermediate text isn't interpreted as a final numeric value. The public docs explicitly say it "Handles composed input from input method editors (e.g. Pinyin)". [1]

Practical consequence: because useNumberField defers committing/parsing partial input, the component often only updates the numeric value (and calls onChange for the parsed value) on blur or compositionend rather than on every keystroke; this is discussed in related GitHub issues about onChange firing only on blur. [1][2]

Sources:

  • React Aria useNumberField docs. [1]
  • react-spectrum / react-aria GitHub issue about useNumberField onChange behavior. [2]


🏁 Script executed:

#!/bin/bash
# Check for composition event handling in the use-number-input.ts file
rg -n "composit|isComposing" packages/components/number-input/src/use-number-input.ts

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look for all event handlers in the file to understand the full implementation
rg -n "handle[A-Z]|useCallback|useRef|useState" packages/components/number-input/src/use-number-input.ts -A 2 | head -100

Length of output: 2613


🏁 Script executed:

#!/bin/bash
# Check if there are any composition event handlers defined
ast-grep --pattern 'onComposition$_'

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look at the complete use-number-input.ts file to understand the full context
wc -l packages/components/number-input/src/use-number-input.ts

Length of output: 60



🏁 Script executed:

#!/bin/bash
# Get full context of handleBeforeInput and surrounding code
sed -n '320,390p' packages/components/number-input/src/use-number-input.ts

Length of output: 2218


🏁 Script executed:

#!/bin/bash
# Search for composition event listeners in the entire number-input directory
fd . packages/components/number-input/src --type f -name "*.ts" -o -name "*.tsx" | xargs rg -l "compositionstart|compositionend|composing" 2>/dev/null || echo "No composition handlers found"

Length of output: 260


🏁 Script executed:

#!/bin/bash
# Check if useNumberFieldState is imported and what it provides
rg -n "useNumberFieldState|from.*numberfield" packages/components/number-input/src/use-number-input.ts -B 2 -A 5

Length of output: 1298



🏁 Script executed:

#!/bin/bash
# Check where inputProps from useAriaNumberInput is used and if composition handlers come from there
sed -n '440,475p' packages/components/number-input/src/use-number-input.ts

Length of output: 1202


🏁 Script executed:

#!/bin/bash
# Look for where useAriaNumberInput is called to see what inputProps it provides
rg -n "useAriaNumberInput|inputProps" packages/components/number-input/src/use-number-input.ts -B 2 -A 2

Length of output: 1467


Add IME composition check to handleBeforeInput in packages/components/number-input/src/use-number-input.ts.

The custom handleBeforeInput handler at lines 332–376 overrides React Aria's composition-aware event handler when shouldFormat is true. React Aria's useNumberField handles composed input from input method editors (e.g. Pinyin) by deferring parsing/validation until composition finishes, but the custom implementation intercepts and prevents default without checking composition state. This breaks input for users typing in languages like Japanese, Chinese, and Korean.

Add a composition check before the preventDefault calls:

const handleBeforeInput = useCallback(
  (e: React.FormEvent<HTMLInputElement> & {data: string | null}) => {
    if (!e.data) return;
    
    // Skip if IME composition is active
    if ((e.nativeEvent as any).isComposing) {
      return;
    }
    
    const input = domRef.current;
    if (!input) return;
    
    // ... rest of the logic
  },
  [numberParser, numberFormatter, state, domRef, onChange],
);
🤖 Prompt for AI Agents
In packages/components/number-input/src/use-number-input.ts around lines 332 to
376, the custom handleBeforeInput unconditionally prevents default and parses
input even when an IME composition is active, breaking CJK input; add an IME
composition guard immediately after the existing "if (!e.data) return" check
(e.g. if ((e.nativeEvent as any).isComposing) return;) so the handler bails out
during composition and only proceeds with parsing/validation/ preventDefault
when composition is not active.

@hasegawa-101
Copy link
Contributor Author

@wingkwong I would appreciate it if you could review the implementation and let me know if the direction is correct 🙏

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.

[BUG] - NumberInput doesn't apply changes on real time

1 participant