@jobber/components 8.23.0 → 8.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/dist/Autocomplete/index.cjs +1 -1
  2. package/dist/Autocomplete/index.mjs +1 -1
  3. package/dist/Card/index.cjs +4 -4
  4. package/dist/Card/index.mjs +4 -4
  5. package/dist/Chip/index.cjs +1 -1
  6. package/dist/Chip/index.mjs +1 -1
  7. package/dist/Chips/InternalChipDismissible/hooks/index.cjs +1 -1
  8. package/dist/Chips/InternalChipDismissible/hooks/index.mjs +1 -1
  9. package/dist/Chips/InternalChipDismissible/index.cjs +1 -1
  10. package/dist/Chips/InternalChipDismissible/index.mjs +1 -1
  11. package/dist/Chips/index.cjs +1 -1
  12. package/dist/Chips/index.mjs +1 -1
  13. package/dist/ComboboxChipRemove-cjs.js +668 -379
  14. package/dist/ComboboxChipRemove-es.js +508 -219
  15. package/dist/ConfirmationModal/index.cjs +1 -1
  16. package/dist/ConfirmationModal/index.mjs +1 -1
  17. package/dist/DataDump/index.cjs +4 -4
  18. package/dist/DataDump/index.mjs +4 -4
  19. package/dist/DataList/components/DataListActions/index.cjs +1 -1
  20. package/dist/DataList/components/DataListActions/index.mjs +1 -1
  21. package/dist/DataList/components/DataListBulkActions/index.cjs +1 -1
  22. package/dist/DataList/components/DataListBulkActions/index.mjs +1 -1
  23. package/dist/DataList/components/DataListFilters/components/DataListSort/index.cjs +1 -1
  24. package/dist/DataList/components/DataListFilters/components/DataListSort/index.mjs +1 -1
  25. package/dist/DataList/components/DataListFilters/index.cjs +1 -1
  26. package/dist/DataList/components/DataListFilters/index.mjs +1 -1
  27. package/dist/DataList/components/DataListHeader/index.cjs +1 -1
  28. package/dist/DataList/components/DataListHeader/index.mjs +1 -1
  29. package/dist/DataList/components/DataListItem/index.cjs +1 -1
  30. package/dist/DataList/components/DataListItem/index.mjs +1 -1
  31. package/dist/DataList/components/DataListItemActions/index.cjs +1 -1
  32. package/dist/DataList/components/DataListItemActions/index.mjs +1 -1
  33. package/dist/DataList/components/DataListItemActionsOverflow/index.cjs +1 -1
  34. package/dist/DataList/components/DataListItemActionsOverflow/index.mjs +1 -1
  35. package/dist/DataList/components/DataListItems/index.cjs +1 -1
  36. package/dist/DataList/components/DataListItems/index.mjs +1 -1
  37. package/dist/DataList/components/DataListLayout/index.cjs +1 -1
  38. package/dist/DataList/components/DataListLayout/index.mjs +1 -1
  39. package/dist/DataList/components/DataListLayoutActions/index.cjs +1 -1
  40. package/dist/DataList/components/DataListLayoutActions/index.mjs +1 -1
  41. package/dist/DataList/index.cjs +1 -1
  42. package/dist/DataList/index.mjs +1 -1
  43. package/dist/DatePicker/index.cjs +1 -1
  44. package/dist/DatePicker/index.mjs +1 -1
  45. package/dist/Dialog/index.cjs +3 -3
  46. package/dist/Dialog/index.mjs +3 -3
  47. package/dist/DrawerDescription-cjs.js +32 -33
  48. package/dist/DrawerDescription-es.js +19 -20
  49. package/dist/DrawerTitle-cjs.js +7 -5
  50. package/dist/DrawerTitle-es.js +7 -5
  51. package/dist/FilterPicker/components/FilterPickerActivator/index.cjs +1 -1
  52. package/dist/FilterPicker/components/FilterPickerActivator/index.mjs +1 -1
  53. package/dist/FilterPicker/components/FilterPickerContent/index.cjs +1 -1
  54. package/dist/FilterPicker/components/FilterPickerContent/index.mjs +1 -1
  55. package/dist/FilterPicker/components/FilterPickerTrigger/index.cjs +1 -1
  56. package/dist/FilterPicker/components/FilterPickerTrigger/index.mjs +1 -1
  57. package/dist/FilterPicker/index.cjs +1 -1
  58. package/dist/FilterPicker/index.mjs +1 -1
  59. package/dist/FormFieldWrapper-cjs.js +2 -2
  60. package/dist/FormFieldWrapper-es.js +2 -2
  61. package/dist/FormatFile/index.cjs +1 -1
  62. package/dist/FormatFile/index.mjs +1 -1
  63. package/dist/Gallery/index.cjs +1 -1
  64. package/dist/Gallery/index.mjs +1 -1
  65. package/dist/InputDate/index.cjs +1 -1
  66. package/dist/InputDate/index.mjs +1 -1
  67. package/dist/InputNumberExperimental-cjs.js +319 -102
  68. package/dist/InputNumberExperimental-es.js +321 -104
  69. package/dist/LightBox/index.cjs +1 -1
  70. package/dist/LightBox/index.mjs +1 -1
  71. package/dist/Menu/index.cjs +4 -4
  72. package/dist/Menu/index.mjs +4 -4
  73. package/dist/MenuSubmenuTrigger-cjs.js +545 -498
  74. package/dist/MenuSubmenuTrigger-es.js +490 -443
  75. package/dist/Modal/index.cjs +1 -1
  76. package/dist/Modal/index.mjs +1 -1
  77. package/dist/NumberFieldInput-cjs.js +609 -496
  78. package/dist/NumberFieldInput-es.js +612 -498
  79. package/dist/Page/index.cjs +4 -4
  80. package/dist/Page/index.mjs +4 -4
  81. package/dist/Popover/index.cjs +1 -1
  82. package/dist/Popover/index.mjs +1 -1
  83. package/dist/ScrollAreaViewport-cjs.js +326 -601
  84. package/dist/ScrollAreaViewport-es.js +278 -548
  85. package/dist/SelectGroupLabel-cjs.js +494 -528
  86. package/dist/SelectGroupLabel-es.js +411 -445
  87. package/dist/Separator-cjs.js +1 -0
  88. package/dist/Separator-es.js +1 -0
  89. package/dist/Tooltip/index.cjs +1 -1
  90. package/dist/Tooltip/index.mjs +1 -1
  91. package/dist/docs/InputTime/InputTime.md +8 -0
  92. package/dist/docs/SelectPrimitive/SelectPrimitive.md +38 -25
  93. package/dist/floating-ui.react-cjs.js +586 -10
  94. package/dist/floating-ui.react-dom-cjs.js +92 -92
  95. package/dist/floating-ui.react-dom-es.js +1 -1
  96. package/dist/floating-ui.react-es.js +580 -4
  97. package/dist/floating-ui.utils-cjs.js +159 -0
  98. package/dist/floating-ui.utils-es.js +136 -0
  99. package/dist/index.cjs +4 -4
  100. package/dist/index.mjs +4 -4
  101. package/dist/{ToolbarRootContext-cjs.js → isElementDisabled-cjs.js} +5 -0
  102. package/dist/{ToolbarRootContext-es.js → isElementDisabled-es.js} +5 -1
  103. package/dist/primitives/BottomSheet/index.cjs +3 -3
  104. package/dist/primitives/BottomSheet/index.mjs +3 -3
  105. package/dist/primitives/ComboboxPrimitive/index.cjs +2 -2
  106. package/dist/primitives/ComboboxPrimitive/index.mjs +2 -2
  107. package/dist/primitives/InputNumberExperimental/index.cjs +1 -1
  108. package/dist/primitives/InputNumberExperimental/index.mjs +1 -1
  109. package/dist/primitives/SelectPrimitive/index.cjs +5 -5
  110. package/dist/primitives/SelectPrimitive/index.mjs +5 -5
  111. package/dist/primitives/index.cjs +4 -4
  112. package/dist/primitives/index.mjs +4 -4
  113. package/dist/resolveAriaLabelledBy-cjs.js +5 -0
  114. package/dist/resolveAriaLabelledBy-es.js +5 -1
  115. package/dist/{CSPContext-cjs.js → scrollEdges-cjs.js} +30 -2
  116. package/dist/{CSPContext-es.js → scrollEdges-es.js} +28 -3
  117. package/dist/styles.css +9 -3
  118. package/dist/unstyledPrimitives/index.cjs +1217 -639
  119. package/dist/unstyledPrimitives/index.mjs +1163 -585
  120. package/dist/useButton-cjs.js +283 -317
  121. package/dist/useButton-es.js +277 -298
  122. package/dist/useCompositeListItem-cjs.js +328 -279
  123. package/dist/useCompositeListItem-es.js +293 -246
  124. package/dist/useLabel-cjs.js +11 -1
  125. package/dist/useLabel-es.js +11 -2
  126. package/dist/useLabelableId-cjs.js +37 -91
  127. package/dist/useLabelableId-es.js +37 -90
  128. package/dist/useRenderElement-cjs.js +81 -26
  129. package/dist/useRenderElement-es.js +81 -27
  130. package/dist/{InternalBackdrop-cjs.js → useScrollLock-cjs.js} +1286 -1220
  131. package/dist/{InternalBackdrop-es.js → useScrollLock-es.js} +1250 -1207
  132. package/package.json +3 -3
  133. package/dist/index.esm-cjs.js +0 -738
  134. package/dist/index.esm-es.js +0 -712
@@ -1,10 +1,11 @@
1
1
  import * as React from 'react';
2
- import { D as useOnMount, T as Timeout, E as useControlled, n as useValueAsRef, a as useIsoLayoutEffect, b as useStableCallback, h as useTimeout, F as isIOS, d as createChangeEventDetails, G as none, H as visuallyHiddenInput, I as visuallyHidden, J as inputChange, K as inputClear, L as inputBlur, M as inputPaste, N as createGenericEventDetails, o as ownerDocument, O as incrementPress, P as decrementPress, c as useButton, Q as useValueChanged, s as stopEvent, R as keyboard } from './useButton-es.js';
3
- import { a as useRefWithInit, f as formatErrorMessage, b as useMergedRefs, u as useRenderElement } from './useRenderElement-es.js';
4
- import { f as fieldValidityMapping, b as useFieldRootContext, c as useLabelableId, a as useFormContext, u as useLabelableContext, d as useField } from './useLabelableId-es.js';
5
- import { c as clamp } from './clamp-es.js';
2
+ import { z as useControlled, m as useValueAsRef, a as useIsoLayoutEffect, b as useStableCallback, A as ios, B as addEventListener, d as createChangeEventDetails, C as none, D as visuallyHiddenInput, E as visuallyHidden, F as inputChange, G as inputClear, H as inputBlur, I as inputPaste, p as activeElement, o as ownerDocument, J as createGenericEventDetails, K as wheel, L as useOnMount, T as Timeout, h as useTimeout, M as incrementPress, N as decrementPress, c as useButton, O as useValueChanged, s as stopEvent, P as keyboard, S as SafeReact } from './useButton-es.js';
3
+ import { f as formatErrorMessage, c as useMergedRefs, u as useRenderElement, a as useRefWithInit, N as NOOP, w as warn } from './useRenderElement-es.js';
4
+ import { f as fieldValidityMapping, b as useFieldRootContext, a as useFormContext, c as useLabelableId, u as useLabelableContext, d as useRegisterFieldControl } from './useLabelableId-es.js';
6
5
  import { jsxs, jsx } from 'react/jsx-runtime';
7
6
  import { o as getWindow } from './floating-ui.utils.dom-es.js';
7
+ import { s as stringifyLocale } from './useLabel-es.js';
8
+ import { c as clamp } from './clamp-es.js';
8
9
 
9
10
  /**
10
11
  * Returns a function that forces a rerender.
@@ -19,7 +20,7 @@ function useForcedRerendering() {
19
20
  const cache = new Map();
20
21
  function getFormatter(locale, options) {
21
22
  const optionsString = JSON.stringify({
22
- locale,
23
+ locale: stringifyLocale(locale),
23
24
  options
24
25
  });
25
26
  const cachedFormatter = cache.get(optionsString);
@@ -36,44 +37,6 @@ function formatNumber(value, locale, options) {
36
37
  }
37
38
  return getFormatter(locale, options).format(value);
38
39
  }
39
- function formatNumberMaxPrecision(value, locale, options) {
40
- return formatNumber(value, locale, {
41
- ...options,
42
- maximumFractionDigits: 20
43
- });
44
- }
45
-
46
- const EMPTY = 0;
47
- class Interval extends Timeout {
48
- static create() {
49
- return new Interval();
50
- }
51
-
52
- /**
53
- * Executes `fn` at `delay` interval, clearing any previously scheduled call.
54
- */
55
- start(delay, fn) {
56
- this.clear();
57
- this.currentId = setInterval(() => {
58
- fn();
59
- }, delay);
60
- }
61
- clear = () => {
62
- if (this.currentId !== EMPTY) {
63
- clearInterval(this.currentId);
64
- this.currentId = EMPTY;
65
- }
66
- };
67
- }
68
-
69
- /**
70
- * A `setInterval` with automatic cleanup and guard.
71
- */
72
- function useInterval() {
73
- const timeout = useRefWithInit(Interval.create).current;
74
- useOnMount(timeout.disposeEffect);
75
- return timeout;
76
- }
77
40
 
78
41
  const NumberFieldRootContext = /*#__PURE__*/React.createContext(undefined);
79
42
  if (process.env.NODE_ENV !== "production") NumberFieldRootContext.displayName = "NumberFieldRootContext";
@@ -85,7 +48,7 @@ function useNumberFieldRootContext() {
85
48
  return context;
86
49
  }
87
50
 
88
- const stateAttributesMapping$1 = {
51
+ const stateAttributesMapping = {
89
52
  inputValue: () => null,
90
53
  value: () => null,
91
54
  ...fieldValidityMapping
@@ -124,12 +87,20 @@ const FULLWIDTH_RE = new RegExp(`[${FULLWIDTH_NUMERALS.join('')}]`, 'g');
124
87
  const HAN_RE = new RegExp(`[${HAN_NUMERALS.join('')}]`, 'g');
125
88
  const PERCENT_RE = new RegExp(`[${PERCENTAGES.join('')}]`);
126
89
  const PERMILLE_RE = new RegExp(`[${PERMILLE.join('')}]`);
90
+ const PERCENT_GLOBAL_RE = new RegExp(PERCENT_RE.source, 'g');
91
+ const PERMILLE_GLOBAL_RE = new RegExp(PERMILLE_RE.source, 'g');
127
92
 
128
- // Detection regexes (non-global to avoid lastIndex side effects)
129
- const ARABIC_DETECT_RE = /[٠١٢٣٤٥٦٧٨٩]/;
130
- const PERSIAN_DETECT_RE = /[۰۱۲۳۴۵۶۷۸۹]/;
131
- const HAN_DETECT_RE = /[零〇一二三四五六七八九]/;
93
+ // Detection regexes (non-global to avoid lastIndex side effects), derived from the numeral arrays
94
+ // so they can't drift out of sync.
95
+ const ARABIC_DETECT_RE = new RegExp(`[${ARABIC_NUMERALS.join('')}]`);
96
+ const PERSIAN_DETECT_RE = new RegExp(`[${PERSIAN_NUMERALS.join('')}]`);
97
+ const HAN_DETECT_RE = new RegExp(`[${HAN_NUMERALS.join('')}]`);
132
98
  const FULLWIDTH_DETECT_RE = new RegExp(`[${FULLWIDTH_NUMERALS.join('')}]`);
99
+
100
+ // Whether the character is a digit in any numeral system the field accepts.
101
+ function isNumeralChar(char) {
102
+ return char >= '0' && char <= '9' || ARABIC_DETECT_RE.test(char) || PERSIAN_DETECT_RE.test(char) || HAN_DETECT_RE.test(char) || FULLWIDTH_DETECT_RE.test(char);
103
+ }
133
104
  const BASE_NON_NUMERIC_SYMBOLS = ['.', ',', FULLWIDTH_DECIMAL, FULLWIDTH_GROUP, '٫', '٬'];
134
105
  const SPACE_SEPARATOR_RE = /\p{Zs}/u;
135
106
  const PLUS_SIGNS_WITH_ASCII = ['+', ...UNICODE_PLUS_SIGNS];
@@ -137,6 +108,10 @@ const MINUS_SIGNS_WITH_ASCII = ['-', ...UNICODE_MINUS_SIGNS];
137
108
  const escapeRegExp = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
138
109
  const escapeClassChar = s => s.replace(/[-\\\]^]/g, m => `\\${m}`); // escape for use inside [...]
139
110
 
111
+ function shiftDecimal(value, exponentDelta) {
112
+ const [coefficient, exponent = '0'] = String(value).split('e');
113
+ return Number(`${coefficient}e${Number(exponent) + exponentDelta}`);
114
+ }
140
115
  const charClassFrom = chars => `[${chars.map(escapeClassChar).join('')}]`;
141
116
  const ANY_MINUS_CLASS = charClassFrom(['-'].concat(UNICODE_MINUS_SIGNS));
142
117
  const ANY_PLUS_CLASS = charClassFrom(['+'].concat(UNICODE_PLUS_SIGNS));
@@ -151,7 +126,9 @@ function getNumberLocaleDetails(locale, options) {
151
126
  result[part.type] = part.value;
152
127
  });
153
128
 
154
- // The formatting options may result in not returning a decimal.
129
+ // The formatting options may omit the decimal separator (e.g. integer formats), so resolve it
130
+ // from the plain locale formatter. This overrides any options-derived decimal too, which is
131
+ // safe because the separator is locale-determined and identical across format styles.
155
132
  getFormatter(locale).formatToParts(0.1).forEach(part => {
156
133
  if (part.type === 'decimal') {
157
134
  result[part.type] = part.value;
@@ -200,7 +177,8 @@ function parseNumber(formattedNumber, locale, options) {
200
177
  const {
201
178
  group,
202
179
  decimal,
203
- currency
180
+ currency,
181
+ exponentSeparator
204
182
  } = getNumberLocaleDetails(computedLocale, options);
205
183
 
206
184
  // Build robust unit regex from all unit parts (such as "km/h")
@@ -223,7 +201,7 @@ function parseNumber(formattedNumber, locale, options) {
223
201
  }
224
202
  }
225
203
  const replacements = [{
226
- regex: group ? groupRegex : null,
204
+ regex: groupRegex,
227
205
  replacement: ''
228
206
  }, {
229
207
  regex: decimal ? new RegExp(escapeRegExp(decimal), 'g') : null,
@@ -258,6 +236,15 @@ function parseNumber(formattedNumber, locale, options) {
258
236
  }, {
259
237
  regex: unitRegex,
260
238
  replacement: ''
239
+ }, {
240
+ regex: PERCENT_GLOBAL_RE,
241
+ replacement: ''
242
+ }, {
243
+ regex: PERMILLE_GLOBAL_RE,
244
+ replacement: ''
245
+ }, {
246
+ regex: exponentSeparator ? new RegExp(escapeRegExp(exponentSeparator), 'g') : null,
247
+ replacement: 'e'
261
248
  },
262
249
  // Numeral systems to ASCII digits
263
250
  {
@@ -287,7 +274,7 @@ function parseNumber(formattedNumber, locale, options) {
287
274
  }
288
275
 
289
276
  // Guard against Infinity inputs (ASCII and symbol)
290
- if (/^[-+]?Infinity$/i.test(input) || /[]/.test(input)) {
277
+ if (/^[-+]?Infinity$/i.test(input) || input.includes('')) {
291
278
  return null;
292
279
  }
293
280
  const parseTarget = (isNegative ? '-' : '') + unformatted;
@@ -297,65 +284,73 @@ function parseNumber(formattedNumber, locale, options) {
297
284
  const hasPercentSymbol = PERCENT_RE.test(formattedNumber) || style === 'percent';
298
285
  const hasPermilleSymbol = PERMILLE_RE.test(formattedNumber);
299
286
  if (hasPermilleSymbol) {
300
- num /= 1000;
287
+ num = shiftDecimal(num, -3);
301
288
  } else if (!isUnitPercent && hasPercentSymbol) {
302
- num /= 100;
289
+ num = shiftDecimal(num, -2);
303
290
  }
304
- if (Number.isNaN(num)) {
291
+ if (!Number.isFinite(num)) {
305
292
  return null;
306
293
  }
307
294
  return num;
308
295
  }
309
296
 
310
- const CHANGE_VALUE_TICK_DELAY = 60;
311
- const START_AUTO_CHANGE_DELAY = 400;
312
- const TOUCH_TIMEOUT = 50;
313
- const MAX_POINTER_MOVES_AFTER_TOUCH = 3;
314
- const SCROLLING_POINTER_MOVE_DISTANCE = 8;
315
- const DEFAULT_STEP = 1;
316
-
297
+ // A relative factor scaled by the step size when snapping (`stepSize * STEP_EPSILON_FACTOR`).
317
298
  const STEP_EPSILON_FACTOR = 1e-10;
318
- function getFractionDigits(format) {
319
- const defaultOptions = getFormatter('en-US').resolvedOptions();
320
- const minimumFractionDigits = format?.minimumFractionDigits ?? defaultOptions.minimumFractionDigits ?? 0;
321
- const maximumFractionDigits = Math.max(format?.maximumFractionDigits ?? defaultOptions.maximumFractionDigits ?? 20, minimumFractionDigits);
322
- return {
323
- maximumFractionDigits,
324
- minimumFractionDigits
325
- };
299
+ // An absolute cap (in the value's own units) on how far floating-point cleanup may move a value.
300
+ // Equal to `STEP_EPSILON_FACTOR` only by coincidence — the two are independent and may diverge.
301
+ const MAX_FLOATING_POINT_CLEANUP_DELTA = 1e-10;
302
+
303
+ // The repo compiles against es2022 Intl types, so model NumberFormat v3 options locally.
304
+ // Delete this once tsconfig.base.json includes es2023.
305
+
306
+ function hasNumberFormatRoundingOptions(format) {
307
+ return format?.maximumFractionDigits != null || format?.minimumFractionDigits != null || format?.maximumSignificantDigits != null || format?.minimumSignificantDigits != null || format?.roundingIncrement != null || format?.roundingMode != null || format?.roundingPriority != null;
326
308
  }
327
- function roundToFractionDigits(value, maximumFractionDigits) {
309
+ function removeFloatingPointErrors(value, format) {
328
310
  if (!Number.isFinite(value)) {
329
311
  return value;
330
312
  }
331
- const digits = Math.min(Math.max(maximumFractionDigits, 0), 20);
332
- return Number(value.toFixed(digits));
333
- }
334
- function removeFloatingPointErrors(value, format) {
335
- const {
336
- maximumFractionDigits
337
- } = getFractionDigits(format);
338
- return roundToFractionDigits(value, maximumFractionDigits);
339
- }
340
- function snapToStep(clampedValue, base, step, mode = 'directional') {
341
- if (step === 0) {
342
- return clampedValue;
313
+ if (!hasNumberFormatRoundingOptions(format)) {
314
+ // Clean binary floating-point noise (e.g. `0.1 + 0.2`) without discarding legitimate
315
+ // precision. The cleanup is delta-bounded, so it cannot tell noise apart from real digits
316
+ // that fall within the same epsilon: arithmetic that produces sub-epsilon precision (e.g.
317
+ // accumulating a high-significance `step` past the first tick) is normalized to ~15
318
+ // significant digits.
319
+ const roundedValue = parseFloat(value.toPrecision(15));
320
+ const cleanupDelta = Math.abs(roundedValue - value);
321
+ // Cap the cleanup delta so `toPrecision(15)` cannot erase meaningful fractional values
322
+ // at large magnitudes, where the relative epsilon alone is too permissive. The trade-off is
323
+ // that from the `2^19` binade (~5.2e5) up, a single ULP exceeds the absolute cap, so genuine
324
+ // stepping noise is left uncleaned there (e.g. `1000000.1 + 0.2` stays `1000000.2999999999`)
325
+ // rather than risk corrupting real precision.
326
+ const cleanupTolerance = Math.min(Number.EPSILON * Math.max(1, Math.abs(value)), MAX_FLOATING_POINT_CLEANUP_DELTA);
327
+ return cleanupDelta <= cleanupTolerance ? roundedValue : value;
328
+ }
329
+ const formatter = getFormatter('en-US', {
330
+ ...format,
331
+ // These options alter only display decoration, not numeric rounding.
332
+ signDisplay: 'auto',
333
+ currencySign: 'standard',
334
+ notation: format.notation === 'compact' ? 'standard' : format.notation,
335
+ useGrouping: false
336
+ });
337
+ const roundedText = formatter.format(value);
338
+ const roundedValue = parseNumber(roundedText, 'en-US', format);
339
+ if (roundedValue === null) {
340
+ return value;
343
341
  }
342
+ return formatter.format(roundedValue) === roundedText ? roundedValue : value;
343
+ }
344
+ function snapToStep(value, base, step, mode = 'directional') {
344
345
  const stepSize = Math.abs(step);
345
346
  const direction = Math.sign(step);
346
347
  const tolerance = stepSize * STEP_EPSILON_FACTOR * direction;
347
- const divisor = mode === 'nearest' ? step : stepSize;
348
- const rawSteps = (clampedValue - base + tolerance) / divisor;
349
- let snappedSteps;
348
+ const rawSteps = value - base + tolerance;
350
349
  if (mode === 'nearest') {
351
- snappedSteps = Math.round(rawSteps);
352
- } else if (direction > 0) {
353
- snappedSteps = Math.floor(rawSteps);
354
- } else {
355
- snappedSteps = Math.ceil(rawSteps);
350
+ return base + Math.round(rawSteps / step) * step;
356
351
  }
357
- const stepForResult = mode === 'nearest' ? step : stepSize;
358
- return base + snappedSteps * stepForResult;
352
+ const snappedSteps = direction > 0 ? Math.floor(rawSteps / stepSize) : Math.ceil(rawSteps / stepSize);
353
+ return base + snappedSteps * stepSize;
359
354
  }
360
355
  function toValidatedNumber(value, {
361
356
  step,
@@ -370,21 +365,31 @@ function toValidatedNumber(value, {
370
365
  if (value === null) {
371
366
  return value;
372
367
  }
373
- const clampedValue = shouldClamp ? clamp(value, minWithDefault, maxWithDefault) : value;
374
- if (step != null && snapOnStep) {
375
- if (step === 0) {
376
- return removeFloatingPointErrors(clampedValue, format);
377
- }
368
+ let nextValue = value;
369
+ if (step != null && snapOnStep && step !== 0) {
370
+ const base = small || minWithDefault === Number.MIN_SAFE_INTEGER ? minWithZeroDefault : minWithDefault;
378
371
 
379
- // If a real minimum is provided, use it
380
- let base = minWithZeroDefault;
381
- if (!small && minWithDefault !== Number.MIN_SAFE_INTEGER) {
382
- base = minWithDefault;
383
- }
384
- const snappedValue = snapToStep(clampedValue, base, step, small ? 'nearest' : 'directional');
385
- return removeFloatingPointErrors(snappedValue, format);
372
+ // Snap before clamping so non-step-aligned boundaries stay reachable.
373
+ nextValue = snapToStep(nextValue, base, step, small ? 'nearest' : 'directional');
374
+ }
375
+
376
+ // Clamp before rounding so a value just outside a fractional boundary (e.g. `0.4` with
377
+ // `min: 0.6`) is pulled in range first, then clamp again after rounding in case rounding
378
+ // nudges it back out. Both passes are needed for non-integer bounds.
379
+ if (shouldClamp) {
380
+ nextValue = clamp(nextValue, minWithDefault, maxWithDefault);
381
+ }
382
+
383
+ // Non-stepping values — parsed input (typing, paste, blur) and externally-supplied controlled
384
+ // values — involve no arithmetic here, so they carry no binary noise to clean and are returned
385
+ // verbatim to keep every digit, including values with more than 15 significant digits. Only
386
+ // stepping/snapping arithmetic can introduce noise (e.g. 0.7 + 0.1), which
387
+ // `removeFloatingPointErrors` cleans.
388
+ if (step == null && !hasNumberFormatRoundingOptions(format)) {
389
+ return nextValue;
386
390
  }
387
- return removeFloatingPointErrors(clampedValue, format);
391
+ const roundedValue = removeFloatingPointErrors(nextValue, format);
392
+ return shouldClamp ? clamp(roundedValue, minWithDefault, maxWithDefault) : roundedValue;
388
393
  }
389
394
 
390
395
  const NumberFieldRoot = /*#__PURE__*/React.forwardRef(function NumberFieldRoot(componentProps, forwardedRef) {
@@ -398,6 +403,7 @@ const NumberFieldRoot = /*#__PURE__*/React.forwardRef(function NumberFieldRoot(c
398
403
  required = false,
399
404
  disabled: disabledProp = false,
400
405
  readOnly = false,
406
+ form,
401
407
  name: nameProp,
402
408
  defaultValue,
403
409
  value: valueProp,
@@ -411,6 +417,7 @@ const NumberFieldRoot = /*#__PURE__*/React.forwardRef(function NumberFieldRoot(c
411
417
  render,
412
418
  className,
413
419
  inputRef: inputRefProp,
420
+ style,
414
421
  ...elementProps
415
422
  } = componentProps;
416
423
  const {
@@ -421,9 +428,11 @@ const NumberFieldRoot = /*#__PURE__*/React.forwardRef(function NumberFieldRoot(c
421
428
  invalid,
422
429
  name: fieldName,
423
430
  state: fieldState,
424
- validation,
425
- shouldValidateOnChange
431
+ validation
426
432
  } = useFieldRootContext();
433
+ const {
434
+ clearErrors
435
+ } = useFormContext();
427
436
  const disabled = fieldDisabled || disabledProp;
428
437
  const name = fieldName ?? nameProp;
429
438
  const step = stepProp === 'any' ? 1 : stepProp;
@@ -455,25 +464,14 @@ const NumberFieldRoot = /*#__PURE__*/React.forwardRef(function NumberFieldRoot(c
455
464
  hasPendingCommitRef.current = false;
456
465
  onValueCommittedProp?.(nextValue, eventDetails);
457
466
  });
458
- const startTickTimeout = useTimeout();
459
- const tickInterval = useInterval();
460
- const intentionalTouchCheckTimeout = useTimeout();
461
- const isPressedRef = React.useRef(false);
462
- const movesAfterTouchRef = React.useRef(0);
463
467
  const allowInputSyncRef = React.useRef(true);
464
468
  const lastChangedValueRef = React.useRef(null);
465
- const unsubscribeFromGlobalContextMenuRef = React.useRef(() => {});
466
469
 
467
470
  // During SSR, the value is formatted on the server, whose locale may differ from the client's
468
471
  // locale. This causes a hydration mismatch, which we manually suppress. This is preferable to
469
472
  // rendering an empty input field and then updating it with the formatted value, as the user
470
473
  // can still see the value prior to hydration, even if it's not formatted correctly.
471
- const [inputValue, setInputValue] = React.useState(() => {
472
- if (valueProp !== undefined) {
473
- return getControlledInputValue(value, locale, format);
474
- }
475
- return formatNumber(value, locale, format);
476
- });
474
+ const [inputValue, setInputValue] = React.useState(() => formatNumber(value, locale, format));
477
475
  const [inputMode, setInputMode] = React.useState('numeric');
478
476
  const getAllowedNonNumericKeys = useStableCallback(() => {
479
477
  const {
@@ -513,9 +511,10 @@ const NumberFieldRoot = /*#__PURE__*/React.forwardRef(function NumberFieldRoot(c
513
511
  }
514
512
  }
515
513
 
516
- // Allow plus sign in all cases; minus sign only when negatives are valid
514
+ // Allow plus sign in all cases; minus sign when negatives are valid, or when out-of-range
515
+ // entry is allowed so native underflow validation can be triggered from the keyboard.
517
516
  PLUS_SIGNS_WITH_ASCII.forEach(key => keys.add(key));
518
- if (minWithDefault < 0) {
517
+ if (minWithDefault < 0 || allowOutOfRange) {
519
518
  MINUS_SIGNS_WITH_ASCII.forEach(key => keys.add(key));
520
519
  }
521
520
  return keys;
@@ -532,10 +531,13 @@ const NumberFieldRoot = /*#__PURE__*/React.forwardRef(function NumberFieldRoot(c
532
531
  const setValue = useStableCallback((unvalidatedValue, details) => {
533
532
  const eventWithOptionalKeyState = details.event;
534
533
  const dir = details.direction;
535
- const reason = details.reason;
536
- // Only allow out-of-range values for direct text entry (native-like behavior).
537
- // Step-based interactions (keyboard arrows, buttons, wheel, scrub) still clamp to min/max.
538
- const shouldClampValue = !allowOutOfRange || !(reason === inputChange || reason === inputBlur || reason === inputPaste || reason === inputClear || reason === none);
534
+
535
+ // Direct text entry (typing, pasting, clearing, autofill) behaves natively; step-based
536
+ // interactions (keyboard arrows, buttons, wheel, scrub) do not.
537
+ const isInputReason = details.reason === inputChange || details.reason === inputClear || details.reason === inputBlur || details.reason === inputPaste || details.reason === none;
538
+
539
+ // Only allow out-of-range values for direct text entry. Step-based interactions still clamp.
540
+ const shouldClampValue = !allowOutOfRange || !isInputReason;
539
541
  const validatedValue = toValidatedNumber(unvalidatedValue, {
540
542
  step: dir ? getStepAmount(eventWithOptionalKeyState) * dir : undefined,
541
543
  format: formatOptionsRef.current,
@@ -547,21 +549,21 @@ const NumberFieldRoot = /*#__PURE__*/React.forwardRef(function NumberFieldRoot(c
547
549
  clamp: shouldClampValue
548
550
  });
549
551
 
550
- // Determine whether we should notify about a change even if the numeric value is unchanged.
551
- // This is needed when the user input is clamped/snapped to the same current value, or when
552
- // the source value differs but validation normalizes to the existing value.
553
- const isInputReason = details.reason === inputChange || details.reason === inputClear || details.reason === inputBlur || details.reason === inputPaste || details.reason === none;
552
+ // Notify about a change even when the numeric value is unchanged for input reasons: the
553
+ // typed text may clamp/snap to the current value, or differ while validation normalizes
554
+ // it back to the existing value.
554
555
  const shouldFireChange = validatedValue !== value || isInputReason && (unvalidatedValue !== value || allowInputSyncRef.current === false);
555
556
  if (shouldFireChange) {
556
- lastChangedValueRef.current = validatedValue;
557
557
  onValueChangeProp?.(validatedValue, details);
558
558
  if (details.isCanceled) {
559
- return shouldFireChange;
559
+ // Report a vetoed change as not applied, so callers don't commit a value never stored.
560
+ return false;
560
561
  }
561
562
  setValueUnwrapped(validatedValue);
562
563
  setDirty(validatedValue !== validityData.initialValue);
563
564
  hasPendingCommitRef.current = true;
564
565
  }
566
+ lastChangedValueRef.current = validatedValue;
565
567
 
566
568
  // Keep the visible input in sync immediately when programmatic changes occur
567
569
  // (increment/decrement, wheel, etc). During direct typing we don't want
@@ -582,68 +584,21 @@ const NumberFieldRoot = /*#__PURE__*/React.forwardRef(function NumberFieldRoot(c
582
584
  reason
583
585
  }) => {
584
586
  const prevValue = currentValue == null ? valueRef.current : currentValue;
585
- const nextValue = typeof prevValue === 'number' ? prevValue + amount * direction : Math.max(0, min ?? 0);
586
587
  const nativeEvent = event;
587
- return setValue(nextValue, createChangeEventDetails(reason, nativeEvent, undefined, {
588
+ if (typeof prevValue !== 'number') {
589
+ // Seed an empty field with 0; `setValue` clamps it to the in-range value nearest 0
590
+ // (e.g. `max` for a negative range). No `direction`: the seed isn't a step, so it must
591
+ // not be directionally snapped.
592
+ return setValue(0, createChangeEventDetails(reason, nativeEvent));
593
+ }
594
+ return setValue(prevValue + amount * direction, createChangeEventDetails(reason, nativeEvent, undefined, {
588
595
  direction
589
596
  }));
590
597
  });
591
- const stopAutoChange = useStableCallback(() => {
592
- intentionalTouchCheckTimeout.clear();
593
- startTickTimeout.clear();
594
- tickInterval.clear();
595
- unsubscribeFromGlobalContextMenuRef.current();
596
- movesAfterTouchRef.current = 0;
597
- });
598
- const startAutoChange = useStableCallback((isIncrement, triggerEvent) => {
599
- stopAutoChange();
600
- if (!inputRef.current) {
601
- return;
602
- }
603
- const win = getWindow(inputRef.current);
604
- function handleContextMenu(event) {
605
- event.preventDefault();
606
- }
607
-
608
- // A global context menu is necessary to prevent the context menu from appearing when the touch
609
- // is slightly outside of the element's hit area.
610
- win.addEventListener('contextmenu', handleContextMenu);
611
- unsubscribeFromGlobalContextMenuRef.current = () => {
612
- win.removeEventListener('contextmenu', handleContextMenu);
613
- };
614
- win.addEventListener('pointerup', event => {
615
- isPressedRef.current = false;
616
- stopAutoChange();
617
- const committed = lastChangedValueRef.current ?? valueRef.current;
618
- const commitReason = isIncrement ? incrementPress : decrementPress;
619
- onValueCommitted(committed, createGenericEventDetails(commitReason, event));
620
- }, {
621
- once: true
622
- });
623
- function tick() {
624
- const amount = getStepAmount(triggerEvent) ?? DEFAULT_STEP;
625
- return incrementValue(amount, {
626
- direction: isIncrement ? 1 : -1,
627
- event: triggerEvent,
628
- reason: isIncrement ? 'increment-press' : 'decrement-press'
629
- });
630
- }
631
- if (!tick()) {
632
- stopAutoChange();
633
- return;
634
- }
635
- startTickTimeout.start(START_AUTO_CHANGE_DELAY, () => {
636
- tickInterval.start(CHANGE_VALUE_TICK_DELAY, () => {
637
- if (!tick()) {
638
- stopAutoChange();
639
- }
640
- });
641
- });
642
- });
643
598
 
644
599
  // We need to update the input value when the external `value` prop changes. This ends up acting
645
600
  // as a single source of truth to update the input value, bypassing the need to manually set it in
646
- // each event handler internally in this hook.
601
+ // each event handler.
647
602
  // This is done inside a layout effect as an alternative to the technique to set state during
648
603
  // render as we're accessing a ref, which must be inside an effect.
649
604
  // https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
@@ -657,13 +612,13 @@ const NumberFieldRoot = /*#__PURE__*/React.forwardRef(function NumberFieldRoot(c
657
612
  if (!allowInputSyncRef.current) {
658
613
  return;
659
614
  }
660
- const nextInputValue = valueProp !== undefined ? getControlledInputValue(value, locale, format) : formatNumber(value, locale, format);
615
+ const nextInputValue = formatNumber(value, locale, format);
661
616
  if (nextInputValue !== inputValue) {
662
617
  setInputValue(nextInputValue);
663
618
  }
664
619
  });
665
620
  useIsoLayoutEffect(function setDynamicInputModeForIOS() {
666
- if (!isIOS) {
621
+ if (!ios) {
667
622
  return;
668
623
  }
669
624
 
@@ -676,12 +631,10 @@ const NumberFieldRoot = /*#__PURE__*/React.forwardRef(function NumberFieldRoot(c
676
631
  computedInputMode = 'decimal';
677
632
  }
678
633
  setInputMode(computedInputMode);
679
- }, [minWithDefault, formatStyle]);
680
- React.useEffect(() => {
681
- return () => stopAutoChange();
682
- }, [stopAutoChange]);
634
+ }, [minWithDefault]);
683
635
 
684
- // The `onWheel` prop can't be prevented, so we need to use a global event listener.
636
+ // React attaches `onWheel` as a passive listener, so calling `preventDefault` there is ignored.
637
+ // Attach a native (non-passive) `wheel` listener to the input instead to prevent page scrolling.
685
638
  React.useEffect(function registerElementWheelListener() {
686
639
  const element = inputRef.current;
687
640
  if (disabled || readOnly || !allowWheelScrub || !element) {
@@ -690,24 +643,28 @@ const NumberFieldRoot = /*#__PURE__*/React.forwardRef(function NumberFieldRoot(c
690
643
  function handleWheel(event) {
691
644
  if (
692
645
  // Allow pinch-zooming.
693
- event.ctrlKey || ownerDocument(inputRef.current).activeElement !== inputRef.current) {
646
+ event.ctrlKey || activeElement(ownerDocument(inputRef.current)) !== inputRef.current) {
694
647
  return;
695
648
  }
696
649
 
697
650
  // Prevent the default behavior to avoid scrolling the page.
698
651
  event.preventDefault();
699
- const amount = getStepAmount(event) ?? DEFAULT_STEP;
700
- incrementValue(amount, {
652
+ allowInputSyncRef.current = true;
653
+ const amount = getStepAmount(event);
654
+
655
+ // Each wheel turn is a discrete, final change, so commit it immediately like keyboard
656
+ // steps (gated on an actual change so boundary no-ops don't commit).
657
+ const changed = incrementValue(amount, {
701
658
  direction: event.deltaY > 0 ? -1 : 1,
702
659
  event,
703
660
  reason: 'wheel'
704
661
  });
662
+ if (changed) {
663
+ onValueCommitted(lastChangedValueRef.current ?? valueRef.current, createGenericEventDetails(wheel, event));
664
+ }
705
665
  }
706
- element.addEventListener('wheel', handleWheel);
707
- return () => {
708
- element.removeEventListener('wheel', handleWheel);
709
- };
710
- }, [allowWheelScrub, incrementValue, disabled, readOnly, largeStep, step, getStepAmount]);
666
+ return addEventListener(element, 'wheel', handleWheel);
667
+ }, [allowWheelScrub, incrementValue, disabled, readOnly, getStepAmount, onValueCommitted, lastChangedValueRef, valueRef]);
711
668
  const state = React.useMemo(() => ({
712
669
  ...fieldState,
713
670
  disabled,
@@ -721,8 +678,6 @@ const NumberFieldRoot = /*#__PURE__*/React.forwardRef(function NumberFieldRoot(c
721
678
  inputRef,
722
679
  inputValue,
723
680
  value,
724
- startAutoChange,
725
- stopAutoChange,
726
681
  minWithDefault,
727
682
  maxWithDefault,
728
683
  disabled,
@@ -736,10 +691,8 @@ const NumberFieldRoot = /*#__PURE__*/React.forwardRef(function NumberFieldRoot(c
736
691
  valueRef,
737
692
  lastChangedValueRef,
738
693
  hasPendingCommitRef,
739
- isPressedRef,
740
- intentionalTouchCheckTimeout,
741
- movesAfterTouchRef,
742
694
  name,
695
+ nameProp,
743
696
  required,
744
697
  invalid,
745
698
  inputMode,
@@ -752,23 +705,23 @@ const NumberFieldRoot = /*#__PURE__*/React.forwardRef(function NumberFieldRoot(c
752
705
  setIsScrubbing,
753
706
  state,
754
707
  onValueCommitted
755
- }), [inputRef, inputValue, value, startAutoChange, stopAutoChange, minWithDefault, maxWithDefault, disabled, readOnly, id, setValue, incrementValue, getStepAmount, formatOptionsRef, valueRef, intentionalTouchCheckTimeout, name, required, invalid, inputMode, getAllowedNonNumericKeys, min, max, setInputValue, locale, isScrubbing, state, onValueCommitted]);
708
+ }), [inputRef, inputValue, value, minWithDefault, maxWithDefault, disabled, readOnly, id, setValue, incrementValue, getStepAmount, formatOptionsRef, valueRef, name, nameProp, required, invalid, inputMode, getAllowedNonNumericKeys, min, max, setInputValue, locale, isScrubbing, state, onValueCommitted]);
756
709
  const element = useRenderElement('div', componentProps, {
757
710
  ref: forwardedRef,
758
711
  state,
759
712
  props: elementProps,
760
- stateAttributesMapping: stateAttributesMapping$1
713
+ stateAttributesMapping
761
714
  });
762
715
  return /*#__PURE__*/jsxs(NumberFieldRootContext.Provider, {
763
716
  value: contextValue,
764
717
  children: [element, /*#__PURE__*/jsx("input", {
765
- ...validation.getInputValidationProps({
718
+ ...validation.getValidationProps(disabled, {
766
719
  onFocus() {
767
720
  inputRef.current?.focus();
768
721
  },
769
722
  onChange(event) {
770
723
  // Workaround for https://github.com/facebook/react/issues/9023
771
- if (event.nativeEvent.defaultPrevented) {
724
+ if (event.nativeEvent.defaultPrevented || disabled || readOnly) {
772
725
  return;
773
726
  }
774
727
 
@@ -776,15 +729,17 @@ const NumberFieldRoot = /*#__PURE__*/React.forwardRef(function NumberFieldRoot(c
776
729
  const nextValue = event.currentTarget.valueAsNumber;
777
730
  const parsedValue = Number.isNaN(nextValue) ? null : nextValue;
778
731
  const details = createChangeEventDetails(none, event.nativeEvent);
779
- setDirty(parsedValue !== validityData.initialValue);
732
+
733
+ // `setValue` updates the dirty flag from the stored (clamped) value, so validate with
734
+ // that same value rather than the raw autofilled one.
780
735
  setValue(parsedValue, details);
781
- if (shouldValidateOnChange()) {
782
- validation.commit(parsedValue);
783
- }
736
+ clearErrors(name);
737
+ validation.change(lastChangedValueRef.current ?? parsedValue);
784
738
  }
785
739
  }),
786
740
  ref: hiddenInputRef,
787
741
  type: "number",
742
+ form: form,
788
743
  name: name,
789
744
  value: value ?? '',
790
745
  min: min,
@@ -794,18 +749,17 @@ const NumberFieldRoot = /*#__PURE__*/React.forwardRef(function NumberFieldRoot(c
794
749
  ,
795
750
  step: stepProp,
796
751
  disabled: disabled,
752
+ readOnly: readOnly,
797
753
  required: required,
798
754
  "aria-hidden": true,
799
755
  tabIndex: -1,
800
- style: name ? visuallyHiddenInput : visuallyHidden
756
+ style: name ? visuallyHiddenInput : visuallyHidden,
757
+ suppressHydrationWarning: true
801
758
  })]
802
759
  });
803
760
  });
804
- if (process.env.NODE_ENV !== "production") NumberFieldRoot.displayName = "NumberFieldRoot";
805
- function getControlledInputValue(value, locale, format) {
806
- const explicitPrecision = format?.maximumFractionDigits != null || format?.minimumFractionDigits != null;
807
- return explicitPrecision ? formatNumber(value, locale, format) : formatNumberMaxPrecision(value, locale, format);
808
- }
761
+ if (process.env.NODE_ENV !== "production") NumberFieldRoot.displayName = "NumberFieldRoot"; // `none` is kept for consistency with other components even though the number field never
762
+ // commits with it.
809
763
 
810
764
  /**
811
765
  * Groups the input with the increment and decrement buttons.
@@ -817,6 +771,7 @@ const NumberFieldGroup = /*#__PURE__*/React.forwardRef(function NumberFieldGroup
817
771
  const {
818
772
  render,
819
773
  className,
774
+ style,
820
775
  ...elementProps
821
776
  } = componentProps;
822
777
  const {
@@ -828,135 +783,175 @@ const NumberFieldGroup = /*#__PURE__*/React.forwardRef(function NumberFieldGroup
828
783
  props: [{
829
784
  role: 'group'
830
785
  }, elementProps],
831
- stateAttributesMapping: stateAttributesMapping$1
786
+ stateAttributesMapping
832
787
  });
833
788
  return element;
834
789
  });
835
790
  if (process.env.NODE_ENV !== "production") NumberFieldGroup.displayName = "NumberFieldGroup";
836
791
 
792
+ const EMPTY = 0;
793
+ class Interval extends Timeout {
794
+ static create() {
795
+ return new Interval();
796
+ }
797
+
798
+ /**
799
+ * Executes `fn` at `delay` interval, clearing any previously scheduled call.
800
+ */
801
+ start(delay, fn) {
802
+ this.clear();
803
+ this.currentId = setInterval(() => {
804
+ fn();
805
+ }, delay);
806
+ }
807
+ clear = () => {
808
+ if (this.currentId !== EMPTY) {
809
+ clearInterval(this.currentId);
810
+ this.currentId = EMPTY;
811
+ }
812
+ };
813
+ }
814
+
815
+ /**
816
+ * A `setInterval` with automatic cleanup and guard.
817
+ */
818
+ function useInterval() {
819
+ const timeout = useRefWithInit(Interval.create).current;
820
+ useOnMount(timeout.disposeEffect);
821
+ return timeout;
822
+ }
823
+
824
+ const DEFAULT_TICK_DELAY = 60;
825
+ const DEFAULT_START_DELAY = 400;
826
+ const DEFAULT_SCROLL_DISTANCE = 8;
827
+ const TOUCH_TIMEOUT = 50;
828
+ const MAX_POINTER_MOVES_AFTER_TOUCH = 3;
829
+
837
830
  // Treat pen as touch-like to avoid forcing the software keyboard on stylus taps.
838
831
  // Linux Chrome may emit "pen" historically for mouse usage due to a bug, but the touch path
839
832
  // still works with minor behavioral differences.
840
833
  function isTouchLikePointerType(pointerType) {
841
834
  return pointerType === 'touch' || pointerType === 'pen';
842
835
  }
843
- function useNumberFieldButton(params) {
836
+ /**
837
+ * Adds press-and-hold behavior to a button element.
838
+ * On pointer down, performs one action immediately, then after a delay starts
839
+ * continuous repeated actions at a fixed interval. Handles mouse, touch, and pen
840
+ * inputs correctly, including Android-specific quirks.
841
+ */
842
+ function usePressAndHold(params) {
844
843
  const {
845
- allowInputSyncRef,
846
844
  disabled,
847
- formatOptionsRef,
848
- getStepAmount,
849
- id,
850
- incrementValue,
851
- inputRef,
852
- inputValue,
853
- intentionalTouchCheckTimeout,
854
- isIncrement,
855
- isPressedRef,
856
- locale,
857
- movesAfterTouchRef,
858
- readOnly,
859
- setValue,
860
- startAutoChange,
861
- stopAutoChange,
862
- valueRef,
863
- lastChangedValueRef,
864
- onValueCommitted
845
+ readOnly = false,
846
+ tick,
847
+ onStop,
848
+ tickDelay = DEFAULT_TICK_DELAY,
849
+ startDelay = DEFAULT_START_DELAY,
850
+ scrollDistance = DEFAULT_SCROLL_DISTANCE,
851
+ elementRef
865
852
  } = params;
866
- const incrementDownCoordsRef = React.useRef({
853
+ const startTickTimeout = useTimeout();
854
+ const tickInterval = useInterval();
855
+ const intentionalTouchCheckTimeout = useTimeout();
856
+ const isPressedRef = React.useRef(false);
857
+ const movesAfterTouchRef = React.useRef(0);
858
+ const downCoordsRef = React.useRef({
867
859
  x: 0,
868
860
  y: 0
869
861
  });
870
862
  const isTouchingButtonRef = React.useRef(false);
871
863
  const ignoreClickRef = React.useRef(false);
872
864
  const pointerTypeRef = React.useRef('');
873
- const pressReason = isIncrement ? 'increment-press' : 'decrement-press';
874
- function commitValue(nativeEvent) {
875
- allowInputSyncRef.current = true;
865
+ const unsubscribeFromGlobalContextMenuRef = React.useRef(NOOP);
866
+ const unsubscribeFromGlobalPointerUpRef = React.useRef(NOOP);
867
+ const stopAutoChange = useStableCallback(() => {
868
+ intentionalTouchCheckTimeout.clear();
869
+ startTickTimeout.clear();
870
+ tickInterval.clear();
871
+ unsubscribeFromGlobalContextMenuRef.current();
872
+ movesAfterTouchRef.current = 0;
873
+ });
874
+ function startAutoChange(triggerNativeEvent) {
875
+ stopAutoChange();
876
+ const element = elementRef.current;
877
+ if (!element) {
878
+ return;
879
+ }
880
+ const win = getWindow(element);
881
+ function handleContextMenu(event) {
882
+ event.preventDefault();
883
+ }
876
884
 
877
- // The input may be dirty but not yet blurred, so the value won't have been committed.
878
- const parsedValue = parseNumber(inputValue, locale, formatOptionsRef.current);
879
- if (parsedValue !== null) {
880
- // The increment value function needs to know the current input value to increment it
881
- // correctly.
882
- valueRef.current = parsedValue;
883
- setValue(parsedValue, createChangeEventDetails(pressReason, nativeEvent, undefined, {
884
- direction: isIncrement ? 1 : -1
885
- }));
885
+ // A global context menu listener is necessary to prevent the context menu from
886
+ // appearing when the touch is slightly outside of the element's hit area.
887
+ unsubscribeFromGlobalContextMenuRef.current = addEventListener(win, 'contextmenu', handleContextMenu);
888
+
889
+ // The release listener stays registered through `stopAutoChange` so a hold that auto-stops at
890
+ // a boundary (a repeat tick returning `false`) still fires `onStop` on release. Replace any
891
+ // existing one first so a mouseleave/mouseenter cycle during a hold doesn't stack listeners
892
+ // (which would otherwise fire `onStop` more than once on release).
893
+ unsubscribeFromGlobalPointerUpRef.current();
894
+ unsubscribeFromGlobalPointerUpRef.current = addEventListener(win, 'pointerup', event => {
895
+ isPressedRef.current = false;
896
+ stopAutoChange();
897
+ onStop?.(event);
898
+ }, {
899
+ once: true
900
+ });
901
+ if (!tick(triggerNativeEvent)) {
902
+ stopAutoChange();
903
+ return;
886
904
  }
905
+ startTickTimeout.start(startDelay, () => {
906
+ tickInterval.start(tickDelay, () => {
907
+ if (!tick(triggerNativeEvent)) {
908
+ stopAutoChange();
909
+ }
910
+ });
911
+ });
887
912
  }
888
- const props = {
889
- disabled,
890
- 'aria-readonly': readOnly || undefined,
891
- 'aria-label': isIncrement ? 'Increase' : 'Decrease',
892
- 'aria-controls': id,
893
- // Keyboard users shouldn't have access to the buttons, since they can use the input element
894
- // to change the value. On the other hand, `aria-hidden` is not applied because touch screen
895
- // readers should be able to use the buttons.
896
- tabIndex: -1,
897
- style: {
898
- WebkitUserSelect: 'none',
899
- userSelect: 'none'
900
- },
913
+ React.useEffect(() => () => {
914
+ stopAutoChange();
915
+ unsubscribeFromGlobalPointerUpRef.current();
916
+ }, [stopAutoChange]);
917
+ const pointerHandlers = {
901
918
  onTouchStart() {
902
919
  isTouchingButtonRef.current = true;
903
920
  },
904
921
  onTouchEnd() {
905
922
  isTouchingButtonRef.current = false;
906
923
  },
907
- onClick(event) {
908
- const isDisabled = disabled || readOnly;
909
- if (event.defaultPrevented || isDisabled || (
910
- // If it's not a keyboard/virtual click, ignore.
911
- isTouchLikePointerType(pointerTypeRef.current) ? ignoreClickRef.current : event.detail !== 0)) {
912
- return;
913
- }
914
- commitValue(event.nativeEvent);
915
- const amount = getStepAmount(event) ?? DEFAULT_STEP;
916
- const prev = valueRef.current;
917
- incrementValue(amount, {
918
- direction: isIncrement ? 1 : -1,
919
- event: event.nativeEvent,
920
- reason: pressReason
921
- });
922
- const committed = lastChangedValueRef.current ?? valueRef.current;
923
- if (committed !== prev) {
924
- onValueCommitted(committed, createGenericEventDetails(pressReason, event.nativeEvent));
925
- }
926
- },
927
924
  onPointerDown(event) {
928
925
  const isMainButton = !event.button || event.button === 0;
929
- if (event.defaultPrevented || readOnly || !isMainButton || disabled) {
926
+ if (event.defaultPrevented || !isMainButton || disabled || readOnly) {
930
927
  return;
931
928
  }
932
929
  pointerTypeRef.current = event.pointerType;
933
930
  ignoreClickRef.current = false;
934
931
  isPressedRef.current = true;
935
- incrementDownCoordsRef.current = {
932
+ downCoordsRef.current = {
936
933
  x: event.clientX,
937
934
  y: event.clientY
938
935
  };
939
- commitValue(event.nativeEvent);
940
936
  const isTouchPointer = isTouchLikePointerType(event.pointerType);
941
937
  if (!isTouchPointer) {
942
938
  event.preventDefault();
943
- inputRef.current?.focus();
944
- startAutoChange(isIncrement, event);
939
+ startAutoChange(event.nativeEvent);
945
940
  } else {
946
- // We need to check if the pointerdown was intentional, and not the result of a scroll
947
- // or pinch-zoom. In that case, we don't want to change the value.
941
+ // Check if the pointerdown was intentional and not the result of a scroll or
942
+ // pinch-zoom. In that case, we don't want to start the auto-change sequence.
948
943
  intentionalTouchCheckTimeout.start(TOUCH_TIMEOUT, () => {
949
944
  const moves = movesAfterTouchRef.current;
950
945
  movesAfterTouchRef.current = 0;
951
946
  // Only start auto-change if the touch is still pressed (prevents races
952
947
  // with pointerup occurring before the timeout fires on quick taps).
953
948
  const stillPressed = isPressedRef.current;
954
- if (stillPressed && moves != null && moves < MAX_POINTER_MOVES_AFTER_TOUCH) {
955
- startAutoChange(isIncrement, event);
956
- ignoreClickRef.current = true; // synthesized click should be ignored
949
+ if (stillPressed && moves < MAX_POINTER_MOVES_AFTER_TOUCH) {
950
+ startAutoChange(event.nativeEvent);
951
+ ignoreClickRef.current = true; // synthesized click after hold should be ignored
957
952
  } else {
958
953
  // No auto-change (simple tap or scroll gesture), allow the click handler
959
- // to perform a single increment and commit.
954
+ // to perform a single action.
960
955
  ignoreClickRef.current = false;
961
956
  stopAutoChange();
962
957
  }
@@ -964,15 +959,14 @@ function useNumberFieldButton(params) {
964
959
  }
965
960
  },
966
961
  onPointerUp(event) {
967
- // Ensure we mark the press as released for touch flows even if auto-change never started,
968
- // so the delayed auto-change check wont start after a quick tap.
962
+ // Ensure we mark the press as released for touch flows even if auto-change never
963
+ // started, so the delayed auto-change check won't start after a quick tap.
969
964
  if (isTouchLikePointerType(event.pointerType)) {
970
965
  isPressedRef.current = false;
971
966
  }
972
967
  },
973
968
  onPointerMove(event) {
974
- const isDisabled = disabled || readOnly;
975
- if (isDisabled || !isTouchLikePointerType(event.pointerType) || !isPressedRef.current) {
969
+ if (disabled || readOnly || !isTouchLikePointerType(event.pointerType) || !isPressedRef.current) {
976
970
  return;
977
971
  }
978
972
  if (movesAfterTouchRef.current != null) {
@@ -981,22 +975,18 @@ function useNumberFieldButton(params) {
981
975
  const {
982
976
  x,
983
977
  y
984
- } = incrementDownCoordsRef.current;
978
+ } = downCoordsRef.current;
985
979
  const dx = x - event.clientX;
986
980
  const dy = y - event.clientY;
987
-
988
- // An alternative to this technique is to detect when the NumberField's parent container
989
- // has been scrolled
990
- if (dx ** 2 + dy ** 2 > SCROLLING_POINTER_MOVE_DISTANCE ** 2) {
981
+ if (dx ** 2 + dy ** 2 > scrollDistance ** 2) {
991
982
  stopAutoChange();
992
983
  }
993
984
  },
994
985
  onMouseEnter(event) {
995
- const isDisabled = disabled || readOnly;
996
- if (event.defaultPrevented || isDisabled || !isPressedRef.current || isTouchingButtonRef.current || isTouchLikePointerType(pointerTypeRef.current)) {
986
+ if (event.defaultPrevented || disabled || readOnly || !isPressedRef.current || isTouchingButtonRef.current || isTouchLikePointerType(pointerTypeRef.current)) {
997
987
  return;
998
988
  }
999
- startAutoChange(isIncrement, event);
989
+ startAutoChange(event.nativeEvent);
1000
990
  },
1001
991
  onMouseLeave() {
1002
992
  if (isTouchingButtonRef.current) {
@@ -1011,105 +1001,160 @@ function useNumberFieldButton(params) {
1011
1001
  stopAutoChange();
1012
1002
  }
1013
1003
  };
1014
- return props;
1004
+ const shouldSkipClick = useStableCallback(event => {
1005
+ if (event.defaultPrevented) {
1006
+ return true;
1007
+ }
1008
+ if (isTouchLikePointerType(pointerTypeRef.current)) {
1009
+ return ignoreClickRef.current;
1010
+ }
1011
+ return event.detail !== 0;
1012
+ });
1013
+ return {
1014
+ pointerHandlers,
1015
+ shouldSkipClick
1016
+ };
1015
1017
  }
1016
1018
 
1017
- /**
1018
- * A stepper button that increases the field value when clicked.
1019
- * Renders an `<button>` element.
1020
- *
1021
- * Documentation: [Base UI Number Field](https://base-ui.com/react/components/number-field)
1022
- */
1023
- const NumberFieldIncrement = /*#__PURE__*/React.forwardRef(function NumberFieldIncrement(componentProps, forwardedRef) {
1024
- const {
1025
- render,
1026
- className,
1027
- disabled: disabledProp = false,
1028
- nativeButton = true,
1029
- ...elementProps
1030
- } = componentProps;
1019
+ const CHANGE_VALUE_TICK_DELAY = 60;
1020
+ const START_AUTO_CHANGE_DELAY = 400;
1021
+ const SCROLLING_POINTER_MOVE_DISTANCE = 8;
1022
+
1023
+ const SELECT_NONE_STYLE = {
1024
+ WebkitUserSelect: 'none',
1025
+ userSelect: 'none'
1026
+ };
1027
+ function useNumberFieldButton(params) {
1031
1028
  const {
1032
1029
  allowInputSyncRef,
1033
- disabled: contextDisabled,
1030
+ disabled,
1034
1031
  formatOptionsRef,
1035
1032
  getStepAmount,
1036
1033
  id,
1037
1034
  incrementValue,
1038
1035
  inputRef,
1039
1036
  inputValue,
1040
- intentionalTouchCheckTimeout,
1041
- isPressedRef,
1037
+ isIncrement,
1042
1038
  locale,
1043
- maxWithDefault,
1044
- movesAfterTouchRef,
1045
1039
  readOnly,
1046
1040
  setValue,
1047
- startAutoChange,
1048
- state,
1049
- stopAutoChange,
1050
- value,
1051
1041
  valueRef,
1052
1042
  lastChangedValueRef,
1053
1043
  onValueCommitted
1054
- } = useNumberFieldRootContext();
1055
- const isMax = value != null && value >= maxWithDefault;
1056
- const disabled = disabledProp || contextDisabled || isMax;
1057
- const props = useNumberFieldButton({
1058
- isIncrement: true,
1059
- inputRef,
1060
- startAutoChange,
1061
- stopAutoChange,
1062
- inputValue,
1063
- disabled,
1064
- readOnly,
1065
- id,
1066
- setValue,
1067
- getStepAmount,
1068
- incrementValue,
1069
- allowInputSyncRef,
1070
- formatOptionsRef,
1071
- valueRef,
1072
- isPressedRef,
1073
- intentionalTouchCheckTimeout,
1074
- movesAfterTouchRef,
1075
- locale,
1076
- lastChangedValueRef,
1077
- onValueCommitted
1078
- });
1044
+ } = params;
1045
+ const pressReason = isIncrement ? incrementPress : decrementPress;
1046
+ function commitValue(nativeEvent) {
1047
+ const shouldCommitInputValue = !allowInputSyncRef.current;
1048
+ allowInputSyncRef.current = true;
1049
+ if (!shouldCommitInputValue) {
1050
+ // The input is already synced, so step from the authoritative numeric value rather than
1051
+ // re-parsing the rounded display text. Refresh the commit ref to the current value so a
1052
+ // subsequent canceled step can't commit a stale `lastChangedValueRef` left over from an
1053
+ // earlier change (the `setValue` that used to refresh it is now skipped on this path).
1054
+ lastChangedValueRef.current = valueRef.current;
1055
+ return;
1056
+ }
1057
+
1058
+ // The input is dirty but not yet blurred, so the value won't have been committed.
1059
+ const parsedValue = parseNumber(inputValue, locale, formatOptionsRef.current);
1060
+ if (parsedValue !== null) {
1061
+ // Sync the dirty typed value with no direction so it isn't directionally snapped
1062
+ // (`snapOnStep`) before the real increment/decrement runs, which would otherwise emit a
1063
+ // spurious intermediate value.
1064
+ const details = createChangeEventDetails(pressReason, nativeEvent);
1065
+ setValue(parsedValue, details);
1066
+
1067
+ // Only sync the ref base when the commit wasn't canceled, so a subsequent increment in the
1068
+ // same interaction steps from the value actually applied.
1069
+ if (!details.isCanceled) {
1070
+ valueRef.current = parsedValue;
1071
+ }
1072
+ }
1073
+ }
1079
1074
  const {
1080
- getButtonProps,
1081
- buttonRef
1082
- } = useButton({
1083
- disabled,
1084
- native: nativeButton,
1085
- focusableWhenDisabled: true
1086
- });
1087
- const buttonState = React.useMemo(() => ({
1088
- ...state,
1089
- disabled
1090
- }), [state, disabled]);
1091
- const element = useRenderElement('button', componentProps, {
1092
- ref: [forwardedRef, buttonRef],
1093
- state: buttonState,
1094
- props: [props, elementProps, getButtonProps],
1095
- stateAttributesMapping: stateAttributesMapping$1
1075
+ pointerHandlers,
1076
+ shouldSkipClick
1077
+ } = usePressAndHold({
1078
+ disabled: disabled || readOnly,
1079
+ elementRef: inputRef,
1080
+ tickDelay: CHANGE_VALUE_TICK_DELAY,
1081
+ startDelay: START_AUTO_CHANGE_DELAY,
1082
+ scrollDistance: SCROLLING_POINTER_MOVE_DISTANCE,
1083
+ tick(triggerEvent) {
1084
+ const amount = getStepAmount(triggerEvent);
1085
+ return incrementValue(amount, {
1086
+ direction: isIncrement ? 1 : -1,
1087
+ event: triggerEvent,
1088
+ reason: pressReason
1089
+ });
1090
+ },
1091
+ onStop(nativeEvent) {
1092
+ // `onStop` fires on every release; fall back to the current value when no tick changed it.
1093
+ // Step interactions never commit `null`, so the `??` can't mask a legitimate null commit.
1094
+ const committed = lastChangedValueRef.current ?? valueRef.current;
1095
+ onValueCommitted(committed, createGenericEventDetails(pressReason, nativeEvent));
1096
+ }
1096
1097
  });
1097
- return element;
1098
- });
1099
- if (process.env.NODE_ENV !== "production") NumberFieldIncrement.displayName = "NumberFieldIncrement";
1098
+ const props = {
1099
+ disabled,
1100
+ 'aria-label': isIncrement ? 'Increase' : 'Decrease',
1101
+ 'aria-controls': id,
1102
+ // Keyboard users shouldn't have access to the buttons, since they can use the input element
1103
+ // to change the value. On the other hand, `aria-hidden` is not applied because touch screen
1104
+ // readers should be able to use the buttons.
1105
+ tabIndex: -1,
1106
+ style: SELECT_NONE_STYLE,
1107
+ ...pointerHandlers,
1108
+ onClick(event) {
1109
+ const isDisabled = disabled || readOnly;
1110
+ if (event.defaultPrevented || isDisabled || shouldSkipClick(event)) {
1111
+ return;
1112
+ }
1113
+ commitValue(event.nativeEvent);
1114
+ const amount = getStepAmount(event);
1115
+ const prev = valueRef.current;
1116
+ incrementValue(amount, {
1117
+ direction: isIncrement ? 1 : -1,
1118
+ event: event.nativeEvent,
1119
+ reason: pressReason
1120
+ });
1121
+ const committed = lastChangedValueRef.current ?? valueRef.current;
1122
+ if (committed !== prev) {
1123
+ onValueCommitted(committed, createGenericEventDetails(pressReason, event.nativeEvent));
1124
+ }
1125
+ },
1126
+ onPointerDown(event) {
1127
+ const isMainButton = !event.button || event.button === 0;
1128
+ if (event.defaultPrevented || readOnly || !isMainButton || disabled) {
1129
+ return;
1130
+ }
1131
+
1132
+ // Sync dirty input value before starting the hold sequence.
1133
+ commitValue(event.nativeEvent);
1134
+ // Treat `lastChangedValueRef` as a per-hold result slot. If the first tick is a no-op or is
1135
+ // canceled, `onStop` should fall back to the current value, not a previous interaction.
1136
+ lastChangedValueRef.current = null;
1137
+ if (!isTouchLikePointerType(event.pointerType)) {
1138
+ // Focus the input so the user can continue with keyboard interactions.
1139
+ inputRef.current?.focus();
1140
+ }
1141
+ pointerHandlers.onPointerDown(event);
1142
+ }
1143
+ };
1144
+ return props;
1145
+ }
1100
1146
 
1101
1147
  /**
1102
- * A stepper button that decreases the field value when clicked.
1103
- * Renders an `<button>` element.
1104
- *
1105
- * Documentation: [Base UI Number Field](https://base-ui.com/react/components/number-field)
1148
+ * Shared implementation for the increment and decrement stepper buttons. They differ only in the
1149
+ * direction they step and the boundary (`max` vs `min`) at which they become disabled.
1106
1150
  */
1107
- const NumberFieldDecrement = /*#__PURE__*/React.forwardRef(function NumberFieldDecrement(componentProps, forwardedRef) {
1151
+ function useNumberFieldStepperButton(componentProps, forwardedRef, isIncrement) {
1108
1152
  const {
1109
1153
  render,
1110
1154
  className,
1111
1155
  disabled: disabledProp = false,
1112
1156
  nativeButton = true,
1157
+ style,
1113
1158
  ...elementProps
1114
1159
  } = componentProps;
1115
1160
  const {
@@ -1121,28 +1166,22 @@ const NumberFieldDecrement = /*#__PURE__*/React.forwardRef(function NumberFieldD
1121
1166
  incrementValue,
1122
1167
  inputRef,
1123
1168
  inputValue,
1124
- intentionalTouchCheckTimeout,
1125
- isPressedRef,
1169
+ maxWithDefault,
1126
1170
  minWithDefault,
1127
- movesAfterTouchRef,
1128
1171
  readOnly,
1129
1172
  setValue,
1130
- startAutoChange,
1131
1173
  state,
1132
- stopAutoChange,
1133
1174
  value,
1134
1175
  valueRef,
1135
1176
  locale,
1136
1177
  lastChangedValueRef,
1137
1178
  onValueCommitted
1138
1179
  } = useNumberFieldRootContext();
1139
- const isMin = value != null && value <= minWithDefault;
1140
- const disabled = disabledProp || contextDisabled || isMin;
1180
+ const isAtBoundary = value != null && (isIncrement ? value >= maxWithDefault : value <= minWithDefault);
1181
+ const disabled = disabledProp || contextDisabled || isAtBoundary;
1141
1182
  const props = useNumberFieldButton({
1142
- isIncrement: false,
1183
+ isIncrement,
1143
1184
  inputRef,
1144
- startAutoChange,
1145
- stopAutoChange,
1146
1185
  inputValue,
1147
1186
  disabled,
1148
1187
  readOnly,
@@ -1153,9 +1192,6 @@ const NumberFieldDecrement = /*#__PURE__*/React.forwardRef(function NumberFieldD
1153
1192
  allowInputSyncRef,
1154
1193
  formatOptionsRef,
1155
1194
  valueRef,
1156
- isPressedRef,
1157
- intentionalTouchCheckTimeout,
1158
- movesAfterTouchRef,
1159
1195
  locale,
1160
1196
  lastChangedValueRef,
1161
1197
  onValueCommitted
@@ -1164,28 +1200,47 @@ const NumberFieldDecrement = /*#__PURE__*/React.forwardRef(function NumberFieldD
1164
1200
  getButtonProps,
1165
1201
  buttonRef
1166
1202
  } = useButton({
1167
- disabled,
1203
+ // Read-only steppers are exposed as unavailable through button disabled semantics, while
1204
+ // `data-readonly` (from `state`) is preserved for styling. `aria-readonly` isn't valid on the
1205
+ // `button` role, so it's intentionally not set.
1206
+ disabled: disabled || readOnly,
1168
1207
  native: nativeButton,
1169
1208
  focusableWhenDisabled: true
1170
1209
  });
1171
- const buttonState = React.useMemo(() => ({
1210
+ const buttonState = {
1172
1211
  ...state,
1173
1212
  disabled
1174
- }), [state, disabled]);
1175
- const element = useRenderElement('button', componentProps, {
1213
+ };
1214
+ return useRenderElement('button', componentProps, {
1176
1215
  ref: [forwardedRef, buttonRef],
1177
1216
  state: buttonState,
1178
1217
  props: [props, elementProps, getButtonProps],
1179
- stateAttributesMapping: stateAttributesMapping$1
1218
+ stateAttributesMapping
1180
1219
  });
1181
- return element;
1220
+ }
1221
+
1222
+ /**
1223
+ * A stepper button that increases the field value when clicked.
1224
+ * Renders a `<button>` element.
1225
+ *
1226
+ * Documentation: [Base UI Number Field](https://base-ui.com/react/components/number-field)
1227
+ */
1228
+ const NumberFieldIncrement = /*#__PURE__*/React.forwardRef(function NumberFieldIncrement(componentProps, forwardedRef) {
1229
+ return useNumberFieldStepperButton(componentProps, forwardedRef, true);
1230
+ });
1231
+ if (process.env.NODE_ENV !== "production") NumberFieldIncrement.displayName = "NumberFieldIncrement";
1232
+
1233
+ /**
1234
+ * A stepper button that decreases the field value when clicked.
1235
+ * Renders a `<button>` element.
1236
+ *
1237
+ * Documentation: [Base UI Number Field](https://base-ui.com/react/components/number-field)
1238
+ */
1239
+ const NumberFieldDecrement = /*#__PURE__*/React.forwardRef(function NumberFieldDecrement(componentProps, forwardedRef) {
1240
+ return useNumberFieldStepperButton(componentProps, forwardedRef, false);
1182
1241
  });
1183
1242
  if (process.env.NODE_ENV !== "production") NumberFieldDecrement.displayName = "NumberFieldDecrement";
1184
1243
 
1185
- const stateAttributesMapping = {
1186
- ...fieldValidityMapping,
1187
- ...stateAttributesMapping$1
1188
- };
1189
1244
  const NAVIGATE_KEYS = new Set(['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab', 'Enter', 'Escape']);
1190
1245
 
1191
1246
  /**
@@ -1198,6 +1253,7 @@ const NumberFieldInput = /*#__PURE__*/React.forwardRef(function NumberFieldInput
1198
1253
  const {
1199
1254
  render,
1200
1255
  className,
1256
+ style,
1201
1257
  ...elementProps
1202
1258
  } = componentProps;
1203
1259
  const {
@@ -1213,6 +1269,7 @@ const NumberFieldInput = /*#__PURE__*/React.forwardRef(function NumberFieldInput
1213
1269
  max,
1214
1270
  min,
1215
1271
  name,
1272
+ nameProp,
1216
1273
  readOnly,
1217
1274
  required,
1218
1275
  setValue,
@@ -1242,28 +1299,25 @@ const NumberFieldInput = /*#__PURE__*/React.forwardRef(function NumberFieldInput
1242
1299
  } = useLabelableContext();
1243
1300
  const hasTouchedInputRef = React.useRef(false);
1244
1301
  const blockRevalidationRef = React.useRef(false);
1245
- useField({
1246
- id,
1247
- commit: validation.commit,
1248
- value,
1249
- controlRef: inputRef,
1250
- name,
1251
- getValue: () => value ?? null
1302
+ const pendingCaretRef = React.useRef(null);
1303
+ useRegisterFieldControl(inputRef, id, value, undefined, !disabled, nameProp);
1304
+
1305
+ // After a paste splices text into the controlled value, the browser would otherwise drop the
1306
+ // caret at the end of the new value. Restore it just after the inserted text.
1307
+ useIsoLayoutEffect(() => {
1308
+ if (pendingCaretRef.current != null) {
1309
+ const caret = pendingCaretRef.current;
1310
+ pendingCaretRef.current = null;
1311
+ inputRef.current?.setSelectionRange(caret, caret);
1312
+ }
1252
1313
  });
1253
- useValueChanged(value, previousValue => {
1254
- const validateOnChange = shouldValidateOnChange();
1314
+ useValueChanged(value, () => {
1255
1315
  clearErrors(name);
1256
- if (validateOnChange) {
1257
- validation.commit(value);
1258
- }
1259
- if (previousValue === value || validateOnChange) {
1260
- return;
1261
- }
1262
- if (blockRevalidationRef.current) {
1316
+ if (blockRevalidationRef.current && !shouldValidateOnChange()) {
1263
1317
  blockRevalidationRef.current = false;
1264
1318
  return;
1265
1319
  }
1266
- validation.commit(value, true);
1320
+ validation.change(value);
1267
1321
  });
1268
1322
  const inputProps = {
1269
1323
  id,
@@ -1277,13 +1331,14 @@ const NumberFieldInput = /*#__PURE__*/React.forwardRef(function NumberFieldInput
1277
1331
  autoCorrect: 'off',
1278
1332
  spellCheck: 'false',
1279
1333
  'aria-roledescription': 'Number field',
1280
- 'aria-invalid': invalid || undefined,
1334
+ 'aria-invalid': !disabled && invalid ? true : undefined,
1281
1335
  'aria-labelledby': labelId,
1282
1336
  // If the server's locale does not match the client's locale, the formatting may not match,
1283
1337
  // causing a hydration mismatch.
1284
1338
  suppressHydrationWarning: true,
1285
1339
  onFocus(event) {
1286
- if (event.defaultPrevented || readOnly || disabled) {
1340
+ // Read-only inputs are still focusable; only the value-changing handlers stay gated on it.
1341
+ if (event.defaultPrevented || disabled) {
1287
1342
  return;
1288
1343
  }
1289
1344
  setFocused(true);
@@ -1299,20 +1354,32 @@ const NumberFieldInput = /*#__PURE__*/React.forwardRef(function NumberFieldInput
1299
1354
  target.setSelectionRange(length, length);
1300
1355
  },
1301
1356
  onBlur(event) {
1302
- if (event.defaultPrevented || readOnly || disabled) {
1357
+ if (event.defaultPrevented || disabled) {
1303
1358
  return;
1304
1359
  }
1305
1360
  setTouched(true);
1306
1361
  setFocused(false);
1362
+ if (readOnly) {
1363
+ return;
1364
+ }
1307
1365
  const hadManualInput = !allowInputSyncRef.current;
1308
1366
  const hadPendingProgrammaticChange = hasPendingCommitRef.current;
1309
1367
  allowInputSyncRef.current = true;
1310
1368
  if (inputValue.trim() === '') {
1311
- setValue(null, createChangeEventDetails(inputClear, event.nativeEvent));
1369
+ const clearDetails = createChangeEventDetails(inputClear, event.nativeEvent);
1370
+ setValue(null, clearDetails);
1371
+ // Respect a canceled clear, mirroring the non-empty blur path below.
1372
+ if (clearDetails.isCanceled) {
1373
+ return;
1374
+ }
1312
1375
  if (validationMode === 'onBlur') {
1313
1376
  validation.commit(null);
1314
1377
  }
1315
- onValueCommitted(null, createGenericEventDetails(inputClear, event.nativeEvent));
1378
+ // Don't report a commit when blurring an already-empty field that the user never
1379
+ // interacted with: nothing was cleared and no programmatic change is pending.
1380
+ if (hadManualInput || hadPendingProgrammaticChange || value !== null) {
1381
+ onValueCommitted(null, createGenericEventDetails(inputClear, event.nativeEvent));
1382
+ }
1316
1383
  return;
1317
1384
  }
1318
1385
  const formatOptions = formatOptionsRef.current;
@@ -1321,29 +1388,52 @@ const NumberFieldInput = /*#__PURE__*/React.forwardRef(function NumberFieldInput
1321
1388
  return;
1322
1389
  }
1323
1390
 
1324
- // If an explicit precision is requested, round the committed numeric value.
1325
- const hasExplicitPrecision = formatOptions?.maximumFractionDigits != null || formatOptions?.minimumFractionDigits != null;
1326
- const maxFrac = formatOptions?.maximumFractionDigits;
1327
- const committed = hasExplicitPrecision && typeof maxFrac === 'number' ? Number(parsedValue.toFixed(maxFrac)) : parsedValue;
1391
+ // Avoid applying Intl's default precision unless the format opts into rounding.
1392
+ const hasRoundingOptions = hasNumberFormatRoundingOptions(formatOptions);
1393
+ let committed;
1394
+ if (!hadManualInput && !hasRoundingOptions) {
1395
+ // No rounding options and no manual edit: the visible text is purely formatted
1396
+ // display, so keep the authoritative numeric value as-is rather than re-parsing the
1397
+ // rounded text and discarding precision (e.g. focus/blur with no edits, or blur after
1398
+ // a programmatic change).
1399
+ committed = value;
1400
+ } else if (hasRoundingOptions) {
1401
+ // Explicit rounding options apply to the committed value, whether typed or external.
1402
+ committed = removeFloatingPointErrors(parsedValue, formatOptions);
1403
+ } else {
1404
+ committed = parsedValue;
1405
+ }
1328
1406
  const nextEventDetails = createGenericEventDetails(inputBlur, event.nativeEvent);
1329
1407
  const shouldUpdateValue = value !== committed;
1330
1408
  const shouldCommit = hadManualInput || shouldUpdateValue || hadPendingProgrammaticChange;
1331
- if (validationMode === 'onBlur') {
1332
- validation.commit(committed);
1333
- }
1409
+
1410
+ // Use the stored value after `setValue` clamps it.
1411
+ let committedValue = committed;
1334
1412
  if (shouldUpdateValue) {
1413
+ const changeDetails = createChangeEventDetails(inputBlur, event.nativeEvent);
1335
1414
  blockRevalidationRef.current = true;
1336
- setValue(committed, createChangeEventDetails(inputBlur, event.nativeEvent));
1415
+ setValue(committed, changeDetails);
1416
+ if (changeDetails.isCanceled) {
1417
+ blockRevalidationRef.current = false;
1418
+ return;
1419
+ }
1420
+ committedValue = lastChangedValueRef.current ?? committed;
1421
+ // If validation normalized back to the current value, `useValueChanged` won't fire to
1422
+ // reset the flag, so reset it here or the next external change won't revalidate.
1423
+ if (committedValue === value) {
1424
+ blockRevalidationRef.current = false;
1425
+ }
1426
+ }
1427
+ if (validationMode === 'onBlur') {
1428
+ validation.commit(committedValue);
1337
1429
  }
1338
1430
  if (shouldCommit) {
1339
- onValueCommitted(committed, nextEventDetails);
1431
+ onValueCommitted(committedValue, nextEventDetails);
1340
1432
  }
1341
1433
 
1342
1434
  // Normalize only the displayed text
1343
- const canonicalText = formatNumber(committed, locale, formatOptions);
1344
- const maxPrecisionText = formatNumberMaxPrecision(parsedValue, locale, formatOptions);
1345
- const shouldPreserveFullPrecision = !hasExplicitPrecision && parsedValue === value && inputValue === maxPrecisionText;
1346
- if (!shouldPreserveFullPrecision && inputValue !== canonicalText) {
1435
+ const canonicalText = formatNumber(committedValue, locale, formatOptions);
1436
+ if (inputValue !== canonicalText) {
1347
1437
  setInputValue(canonicalText);
1348
1438
  }
1349
1439
  },
@@ -1353,7 +1443,7 @@ const NumberFieldInput = /*#__PURE__*/React.forwardRef(function NumberFieldInput
1353
1443
  return;
1354
1444
  }
1355
1445
  allowInputSyncRef.current = false;
1356
- const targetValue = event.target.value;
1446
+ const targetValue = event.currentTarget.value;
1357
1447
  if (targetValue.trim() === '') {
1358
1448
  setInputValue(targetValue);
1359
1449
  setValue(null, createChangeEventDetails(inputClear, event.nativeEvent));
@@ -1364,15 +1454,7 @@ const NumberFieldInput = /*#__PURE__*/React.forwardRef(function NumberFieldInput
1364
1454
  // currently parseable into a number. This preserves good UX for IME
1365
1455
  // composition/partial input while still providing live numeric updates when possible.
1366
1456
  const allowedNonNumericKeys = getAllowedNonNumericKeys();
1367
- const isValidCharacterString = Array.from(targetValue).every(ch => {
1368
- const isAsciiDigit = ch >= '0' && ch <= '9';
1369
- const isArabicNumeral = ARABIC_DETECT_RE.test(ch);
1370
- const isHanNumeral = HAN_DETECT_RE.test(ch);
1371
- const isPersianNumeral = PERSIAN_DETECT_RE.test(ch);
1372
- const isFullwidthNumeral = FULLWIDTH_DETECT_RE.test(ch);
1373
- const isMinus = ANY_MINUS_DETECT_RE.test(ch);
1374
- return isAsciiDigit || isArabicNumeral || isHanNumeral || isPersianNumeral || isFullwidthNumeral || isMinus || allowedNonNumericKeys.has(ch);
1375
- });
1457
+ const isValidCharacterString = Array.from(targetValue).every(ch => isNumeralChar(ch) || ANY_MINUS_DETECT_RE.test(ch) || allowedNonNumericKeys.has(ch));
1376
1458
  if (!isValidCharacterString) {
1377
1459
  return;
1378
1460
  }
@@ -1387,7 +1469,12 @@ const NumberFieldInput = /*#__PURE__*/React.forwardRef(function NumberFieldInput
1387
1469
  return;
1388
1470
  }
1389
1471
  const nativeEvent = event.nativeEvent;
1390
- allowInputSyncRef.current = true;
1472
+
1473
+ // Snapshot the dirty state without clearing it: navigation/allowed keys (ArrowLeft, Tab,
1474
+ // Enter, Escape, …) return early without changing the value, so marking the input synced
1475
+ // here would wrongly discard dirty-input authority. Only the value-changing branches below
1476
+ // mark it synced.
1477
+ const hadManualInput = !allowInputSyncRef.current;
1391
1478
  const allowedNonNumericKeys = getAllowedNonNumericKeys();
1392
1479
  let isAllowedNonNumericKey = allowedNonNumericKeys.has(event.key);
1393
1480
  const {
@@ -1398,20 +1485,19 @@ const NumberFieldInput = /*#__PURE__*/React.forwardRef(function NumberFieldInput
1398
1485
  const selectionStart = event.currentTarget.selectionStart;
1399
1486
  const selectionEnd = event.currentTarget.selectionEnd;
1400
1487
  const isAllSelected = selectionStart === 0 && selectionEnd === inputValue.length;
1401
-
1402
- // Normalize handling of plus/minus signs via precomputed regexes
1403
1488
  const selectionContainsIndex = index => selectionStart != null && selectionEnd != null && index >= selectionStart && index < selectionEnd;
1404
- if (ANY_MINUS_DETECT_RE.test(event.key) && Array.from(allowedNonNumericKeys).some(k => ANY_MINUS_DETECT_RE.test(k || ''))) {
1405
- // Only allow one sign unless replacing the existing one or all text is selected
1406
- const existingIndex = inputValue.search(ANY_MINUS_RE);
1407
- const isReplacingExisting = existingIndex != null && existingIndex !== -1 && selectionContainsIndex(existingIndex);
1408
- isAllowedNonNumericKey = !(ANY_MINUS_DETECT_RE.test(inputValue) || ANY_PLUS_DETECT_RE.test(inputValue)) || isAllSelected || isReplacingExisting;
1409
- }
1410
- if (ANY_PLUS_DETECT_RE.test(event.key) && Array.from(allowedNonNumericKeys).some(k => ANY_PLUS_DETECT_RE.test(k || ''))) {
1411
- const existingIndex = inputValue.search(ANY_PLUS_RE);
1412
- const isReplacingExisting = existingIndex != null && existingIndex !== -1 && selectionContainsIndex(existingIndex);
1413
- isAllowedNonNumericKey = !(ANY_MINUS_DETECT_RE.test(inputValue) || ANY_PLUS_DETECT_RE.test(inputValue)) || isAllSelected || isReplacingExisting;
1414
- }
1489
+
1490
+ // Only allow a single sign character: permit it when there is no existing sign of either
1491
+ // kind, when all text is selected, or when the selection covers the existing sign so it's
1492
+ // being replaced.
1493
+ const signGroups = [[ANY_MINUS_DETECT_RE, ANY_MINUS_RE], [ANY_PLUS_DETECT_RE, ANY_PLUS_RE]];
1494
+ signGroups.forEach(([detectRe, globalRe]) => {
1495
+ if (detectRe.test(event.key) && Array.from(allowedNonNumericKeys).some(k => detectRe.test(k))) {
1496
+ const existingIndex = inputValue.search(globalRe);
1497
+ const isReplacingExisting = existingIndex !== -1 && selectionContainsIndex(existingIndex);
1498
+ isAllowedNonNumericKey = !(ANY_MINUS_DETECT_RE.test(inputValue) || ANY_PLUS_DETECT_RE.test(inputValue)) || isAllSelected || isReplacingExisting;
1499
+ }
1500
+ });
1415
1501
 
1416
1502
  // Only allow one of each symbol.
1417
1503
  [decimal, currency, percentSign].forEach(symbol => {
@@ -1421,47 +1507,59 @@ const NumberFieldInput = /*#__PURE__*/React.forwardRef(function NumberFieldInput
1421
1507
  isAllowedNonNumericKey = !inputValue.includes(symbol) || isAllSelected || isSymbolHighlighted;
1422
1508
  }
1423
1509
  });
1424
- const isAsciiDigit = event.key >= '0' && event.key <= '9';
1425
- const isArabicNumeral = ARABIC_DETECT_RE.test(event.key);
1426
- const isHanNumeral = HAN_DETECT_RE.test(event.key);
1427
- const isFullwidthNumeral = FULLWIDTH_DETECT_RE.test(event.key);
1428
1510
  const isNavigateKey = NAVIGATE_KEYS.has(event.key);
1511
+ // Alt+ArrowUp/ArrowDown selects smallStep, so don't treat it as a bypass modifier.
1512
+ const isStepKey = event.key === 'ArrowUp' || event.key === 'ArrowDown';
1429
1513
  if (
1430
1514
  // Allow composition events (e.g., pinyin)
1431
1515
  // event.nativeEvent.isComposing does not work in Safari:
1432
1516
  // https://bugs.webkit.org/show_bug.cgi?id=165004
1433
- event.which === 229 || event.altKey || event.ctrlKey || event.metaKey || isAllowedNonNumericKey || isAsciiDigit || isArabicNumeral || isFullwidthNumeral || isHanNumeral || isNavigateKey) {
1517
+ event.which === 229 || event.altKey && !isStepKey || event.ctrlKey || event.metaKey || isAllowedNonNumericKey || isNumeralChar(event.key) || isNavigateKey) {
1518
+ return;
1519
+ }
1520
+ const willSetHome = event.key === 'Home' && min != null;
1521
+ const willSetEnd = event.key === 'End' && max != null;
1522
+
1523
+ // Let the browser handle multi-character keys we don't act on (PageUp, Insert, F-keys,
1524
+ // Home/End without min/max); invalid single characters are still blocked below.
1525
+ if (event.key.length > 1 && !isStepKey && !willSetHome && !willSetEnd) {
1434
1526
  return;
1435
1527
  }
1436
1528
 
1437
- // We need to commit the number at this point if the input hasn't been blurred.
1438
- const parsedValue = parseNumber(inputValue, locale, formatOptionsRef.current);
1439
- const amount = getStepAmount(event) ?? DEFAULT_STEP;
1529
+ // Step from the authoritative numeric value unless the input has unsaved manual edits.
1530
+ // When the text is already synced, parsing the rounded display would collapse precision,
1531
+ // so pass no `currentValue` and let `incrementValue` fall back to the numeric state
1532
+ // (mirrors the button path).
1533
+ const currentValue = hadManualInput ? parseNumber(inputValue, locale, formatOptionsRef.current) : null;
1534
+ const amount = getStepAmount(event);
1440
1535
 
1441
1536
  // Prevent insertion of text or caret from moving.
1442
1537
  stopEvent(event);
1443
1538
  const commitDetails = createGenericEventDetails(keyboard, nativeEvent);
1444
- if (event.key === 'ArrowUp') {
1445
- incrementValue(amount, {
1446
- direction: 1,
1447
- currentValue: parsedValue,
1448
- event: nativeEvent,
1449
- reason: keyboard
1450
- });
1451
- onValueCommitted(lastChangedValueRef.current ?? valueRef.current, commitDetails);
1452
- } else if (event.key === 'ArrowDown') {
1453
- incrementValue(amount, {
1454
- direction: -1,
1455
- currentValue: parsedValue,
1539
+ let changed = false;
1540
+ if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
1541
+ allowInputSyncRef.current = true;
1542
+
1543
+ // When stepping from the synced numeric state, refresh the commit ref to the current
1544
+ // value so a canceled step can't commit a stale `lastChangedValueRef` left over from an
1545
+ // earlier change (mirrors the button path).
1546
+ if (!hadManualInput) {
1547
+ lastChangedValueRef.current = valueRef.current;
1548
+ }
1549
+ changed = incrementValue(amount, {
1550
+ direction: event.key === 'ArrowUp' ? 1 : -1,
1551
+ currentValue,
1456
1552
  event: nativeEvent,
1457
1553
  reason: keyboard
1458
1554
  });
1459
- onValueCommitted(lastChangedValueRef.current ?? valueRef.current, commitDetails);
1460
- } else if (event.key === 'Home' && min != null) {
1461
- setValue(min, createChangeEventDetails(keyboard, nativeEvent));
1462
- onValueCommitted(lastChangedValueRef.current ?? valueRef.current, commitDetails);
1463
- } else if (event.key === 'End' && max != null) {
1464
- setValue(max, createChangeEventDetails(keyboard, nativeEvent));
1555
+ } else if (willSetHome) {
1556
+ allowInputSyncRef.current = true;
1557
+ changed = setValue(min, createChangeEventDetails(keyboard, nativeEvent));
1558
+ } else if (willSetEnd) {
1559
+ allowInputSyncRef.current = true;
1560
+ changed = setValue(max, createChangeEventDetails(keyboard, nativeEvent));
1561
+ }
1562
+ if (changed) {
1465
1563
  onValueCommitted(lastChangedValueRef.current ?? valueRef.current, commitDetails);
1466
1564
  }
1467
1565
  },
@@ -1469,27 +1567,43 @@ const NumberFieldInput = /*#__PURE__*/React.forwardRef(function NumberFieldInput
1469
1567
  if (event.defaultPrevented || readOnly || disabled) {
1470
1568
  return;
1471
1569
  }
1570
+ let pastedData = '';
1571
+ try {
1572
+ pastedData = event.clipboardData?.getData('text/plain') ?? '';
1573
+ } catch {
1574
+ if (process.env.NODE_ENV !== 'production') {
1575
+ const ownerStackMessage = SafeReact.captureOwnerStack?.() || '';
1576
+ warn('<NumberField.Input> could not read clipboard text during paste handling.', ownerStackMessage);
1577
+ }
1578
+ return;
1579
+ }
1472
1580
 
1473
1581
  // Prevent `onChange` from being called.
1474
1582
  event.preventDefault();
1475
- const clipboardData = event.clipboardData || window.Clipboard;
1476
- const pastedData = clipboardData.getData('text/plain');
1477
- const parsedValue = parseNumber(pastedData, locale, formatOptionsRef.current);
1583
+
1584
+ // Insert the pasted text at the caret/selection instead of replacing the entire value,
1585
+ // matching native input behavior (e.g. pasting "5" into "123|" yields "1235").
1586
+ const input = event.currentTarget;
1587
+ const selectionStart = input.selectionStart ?? inputValue.length;
1588
+ const selectionEnd = input.selectionEnd ?? inputValue.length;
1589
+ const nextText = inputValue.slice(0, selectionStart) + pastedData + inputValue.slice(selectionEnd);
1590
+ const parsedValue = parseNumber(nextText, locale, formatOptionsRef.current);
1478
1591
  if (parsedValue !== null) {
1479
1592
  allowInputSyncRef.current = false;
1593
+ pendingCaretRef.current = selectionStart + pastedData.length;
1480
1594
  setValue(parsedValue, createChangeEventDetails(inputPaste, event.nativeEvent));
1481
- setInputValue(pastedData);
1595
+ setInputValue(nextText);
1482
1596
  }
1483
1597
  }
1484
1598
  };
1485
1599
  const element = useRenderElement('input', componentProps, {
1486
1600
  ref: [forwardedRef, inputRef],
1487
1601
  state,
1488
- props: [inputProps, validation.getValidationProps(), elementProps],
1602
+ props: [inputProps, elementProps, props => validation.getValidationProps(disabled, props)],
1489
1603
  stateAttributesMapping
1490
1604
  });
1491
1605
  return element;
1492
1606
  });
1493
1607
  if (process.env.NODE_ENV !== "production") NumberFieldInput.displayName = "NumberFieldInput";
1494
1608
 
1495
- export { DEFAULT_STEP as D, NumberFieldRoot as N, NumberFieldGroup as a, NumberFieldInput as b, NumberFieldIncrement as c, NumberFieldDecrement as d, stateAttributesMapping$1 as s, useNumberFieldRootContext as u };
1609
+ export { NumberFieldRoot as N, NumberFieldGroup as a, NumberFieldInput as b, NumberFieldIncrement as c, NumberFieldDecrement as d, stateAttributesMapping as s, useNumberFieldRootContext as u };