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