@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.
- package/dist/Autocomplete/index.cjs +1 -1
- package/dist/Autocomplete/index.mjs +1 -1
- package/dist/Card/index.cjs +4 -4
- package/dist/Card/index.mjs +4 -4
- package/dist/Chip/index.cjs +1 -1
- package/dist/Chip/index.mjs +1 -1
- package/dist/Chips/InternalChipDismissible/hooks/index.cjs +1 -1
- package/dist/Chips/InternalChipDismissible/hooks/index.mjs +1 -1
- package/dist/Chips/InternalChipDismissible/index.cjs +1 -1
- package/dist/Chips/InternalChipDismissible/index.mjs +1 -1
- package/dist/Chips/index.cjs +1 -1
- package/dist/Chips/index.mjs +1 -1
- package/dist/ComboboxChipRemove-cjs.js +668 -379
- package/dist/ComboboxChipRemove-es.js +508 -219
- package/dist/ConfirmationModal/index.cjs +1 -1
- package/dist/ConfirmationModal/index.mjs +1 -1
- package/dist/DataDump/index.cjs +4 -4
- package/dist/DataDump/index.mjs +4 -4
- package/dist/DataList/components/DataListActions/index.cjs +1 -1
- package/dist/DataList/components/DataListActions/index.mjs +1 -1
- package/dist/DataList/components/DataListBulkActions/index.cjs +1 -1
- package/dist/DataList/components/DataListBulkActions/index.mjs +1 -1
- package/dist/DataList/components/DataListFilters/components/DataListSort/index.cjs +1 -1
- package/dist/DataList/components/DataListFilters/components/DataListSort/index.mjs +1 -1
- package/dist/DataList/components/DataListFilters/index.cjs +1 -1
- package/dist/DataList/components/DataListFilters/index.mjs +1 -1
- package/dist/DataList/components/DataListHeader/index.cjs +1 -1
- package/dist/DataList/components/DataListHeader/index.mjs +1 -1
- package/dist/DataList/components/DataListItem/index.cjs +1 -1
- package/dist/DataList/components/DataListItem/index.mjs +1 -1
- package/dist/DataList/components/DataListItemActions/index.cjs +1 -1
- package/dist/DataList/components/DataListItemActions/index.mjs +1 -1
- package/dist/DataList/components/DataListItemActionsOverflow/index.cjs +1 -1
- package/dist/DataList/components/DataListItemActionsOverflow/index.mjs +1 -1
- package/dist/DataList/components/DataListItems/index.cjs +1 -1
- package/dist/DataList/components/DataListItems/index.mjs +1 -1
- package/dist/DataList/components/DataListLayout/index.cjs +1 -1
- package/dist/DataList/components/DataListLayout/index.mjs +1 -1
- package/dist/DataList/components/DataListLayoutActions/index.cjs +1 -1
- package/dist/DataList/components/DataListLayoutActions/index.mjs +1 -1
- package/dist/DataList/index.cjs +1 -1
- package/dist/DataList/index.mjs +1 -1
- package/dist/DatePicker/index.cjs +1 -1
- package/dist/DatePicker/index.mjs +1 -1
- package/dist/Dialog/index.cjs +3 -3
- package/dist/Dialog/index.mjs +3 -3
- package/dist/DrawerDescription-cjs.js +32 -33
- package/dist/DrawerDescription-es.js +19 -20
- package/dist/DrawerTitle-cjs.js +7 -5
- package/dist/DrawerTitle-es.js +7 -5
- package/dist/FilterPicker/components/FilterPickerActivator/index.cjs +1 -1
- package/dist/FilterPicker/components/FilterPickerActivator/index.mjs +1 -1
- package/dist/FilterPicker/components/FilterPickerContent/index.cjs +1 -1
- package/dist/FilterPicker/components/FilterPickerContent/index.mjs +1 -1
- package/dist/FilterPicker/components/FilterPickerTrigger/index.cjs +1 -1
- package/dist/FilterPicker/components/FilterPickerTrigger/index.mjs +1 -1
- package/dist/FilterPicker/index.cjs +1 -1
- package/dist/FilterPicker/index.mjs +1 -1
- package/dist/FormFieldWrapper-cjs.js +2 -2
- package/dist/FormFieldWrapper-es.js +2 -2
- package/dist/FormatFile/index.cjs +1 -1
- package/dist/FormatFile/index.mjs +1 -1
- package/dist/Gallery/index.cjs +1 -1
- package/dist/Gallery/index.mjs +1 -1
- package/dist/InputDate/index.cjs +1 -1
- package/dist/InputDate/index.mjs +1 -1
- package/dist/InputNumberExperimental-cjs.js +319 -102
- package/dist/InputNumberExperimental-es.js +321 -104
- package/dist/LightBox/index.cjs +1 -1
- package/dist/LightBox/index.mjs +1 -1
- package/dist/Menu/index.cjs +4 -4
- package/dist/Menu/index.mjs +4 -4
- package/dist/MenuSubmenuTrigger-cjs.js +545 -498
- package/dist/MenuSubmenuTrigger-es.js +490 -443
- package/dist/Modal/index.cjs +1 -1
- package/dist/Modal/index.mjs +1 -1
- package/dist/NumberFieldInput-cjs.js +609 -496
- package/dist/NumberFieldInput-es.js +612 -498
- package/dist/Page/index.cjs +4 -4
- package/dist/Page/index.mjs +4 -4
- package/dist/Popover/index.cjs +1 -1
- package/dist/Popover/index.mjs +1 -1
- package/dist/ScrollAreaViewport-cjs.js +326 -601
- package/dist/ScrollAreaViewport-es.js +278 -548
- package/dist/SelectGroupLabel-cjs.js +494 -528
- package/dist/SelectGroupLabel-es.js +411 -445
- package/dist/Separator-cjs.js +1 -0
- package/dist/Separator-es.js +1 -0
- package/dist/Tooltip/index.cjs +1 -1
- package/dist/Tooltip/index.mjs +1 -1
- package/dist/docs/InputTime/InputTime.md +8 -0
- package/dist/docs/SelectPrimitive/SelectPrimitive.md +38 -25
- package/dist/floating-ui.react-cjs.js +586 -10
- package/dist/floating-ui.react-dom-cjs.js +92 -92
- package/dist/floating-ui.react-dom-es.js +1 -1
- package/dist/floating-ui.react-es.js +580 -4
- package/dist/floating-ui.utils-cjs.js +159 -0
- package/dist/floating-ui.utils-es.js +136 -0
- package/dist/index.cjs +4 -4
- package/dist/index.mjs +4 -4
- package/dist/{ToolbarRootContext-cjs.js → isElementDisabled-cjs.js} +5 -0
- package/dist/{ToolbarRootContext-es.js → isElementDisabled-es.js} +5 -1
- package/dist/primitives/BottomSheet/index.cjs +3 -3
- package/dist/primitives/BottomSheet/index.mjs +3 -3
- package/dist/primitives/ComboboxPrimitive/index.cjs +2 -2
- package/dist/primitives/ComboboxPrimitive/index.mjs +2 -2
- package/dist/primitives/InputNumberExperimental/index.cjs +1 -1
- package/dist/primitives/InputNumberExperimental/index.mjs +1 -1
- package/dist/primitives/SelectPrimitive/index.cjs +5 -5
- package/dist/primitives/SelectPrimitive/index.mjs +5 -5
- package/dist/primitives/index.cjs +4 -4
- package/dist/primitives/index.mjs +4 -4
- package/dist/resolveAriaLabelledBy-cjs.js +5 -0
- package/dist/resolveAriaLabelledBy-es.js +5 -1
- package/dist/{CSPContext-cjs.js → scrollEdges-cjs.js} +30 -2
- package/dist/{CSPContext-es.js → scrollEdges-es.js} +28 -3
- package/dist/styles.css +9 -3
- package/dist/unstyledPrimitives/index.cjs +1217 -639
- package/dist/unstyledPrimitives/index.mjs +1163 -585
- package/dist/useButton-cjs.js +283 -317
- package/dist/useButton-es.js +277 -298
- package/dist/useCompositeListItem-cjs.js +328 -279
- package/dist/useCompositeListItem-es.js +293 -246
- package/dist/useLabel-cjs.js +11 -1
- package/dist/useLabel-es.js +11 -2
- package/dist/useLabelableId-cjs.js +37 -91
- package/dist/useLabelableId-es.js +37 -90
- package/dist/useRenderElement-cjs.js +81 -26
- package/dist/useRenderElement-es.js +81 -27
- package/dist/{InternalBackdrop-cjs.js → useScrollLock-cjs.js} +1286 -1220
- package/dist/{InternalBackdrop-es.js → useScrollLock-es.js} +1250 -1207
- package/package.json +3 -3
- package/dist/index.esm-cjs.js +0 -738
- package/dist/index.esm-es.js +0 -712
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { f as fieldValidityMapping, b as useFieldRootContext,
|
|
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
|
|
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
|
-
|
|
130
|
-
const
|
|
131
|
-
const
|
|
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
|
|
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:
|
|
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) ||
|
|
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
|
|
287
|
+
num = shiftDecimal(num, -3);
|
|
301
288
|
} else if (!isUnitPercent && hasPercentSymbol) {
|
|
302
|
-
num
|
|
289
|
+
num = shiftDecimal(num, -2);
|
|
303
290
|
}
|
|
304
|
-
if (Number.
|
|
291
|
+
if (!Number.isFinite(num)) {
|
|
305
292
|
return null;
|
|
306
293
|
}
|
|
307
294
|
return num;
|
|
308
295
|
}
|
|
309
296
|
|
|
310
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
|
309
|
+
function removeFloatingPointErrors(value, format) {
|
|
328
310
|
if (!Number.isFinite(value)) {
|
|
329
311
|
return value;
|
|
330
312
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
|
348
|
-
const rawSteps = (clampedValue - base + tolerance) / divisor;
|
|
349
|
-
let snappedSteps;
|
|
348
|
+
const rawSteps = value - base + tolerance;
|
|
350
349
|
if (mode === 'nearest') {
|
|
351
|
-
|
|
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
|
|
358
|
-
return base + snappedSteps *
|
|
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
|
-
|
|
374
|
-
if (step != null && snapOnStep) {
|
|
375
|
-
|
|
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
|
-
//
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
const
|
|
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
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 (!
|
|
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
|
|
680
|
-
React.useEffect(() => {
|
|
681
|
-
return () => stopAutoChange();
|
|
682
|
-
}, [stopAutoChange]);
|
|
634
|
+
}, [minWithDefault]);
|
|
683
635
|
|
|
684
|
-
//
|
|
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)
|
|
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
|
-
|
|
700
|
-
|
|
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
|
-
|
|
707
|
-
|
|
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,
|
|
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
|
|
713
|
+
stateAttributesMapping
|
|
761
714
|
});
|
|
762
715
|
return /*#__PURE__*/jsxs(NumberFieldRootContext.Provider, {
|
|
763
716
|
value: contextValue,
|
|
764
717
|
children: [element, /*#__PURE__*/jsx("input", {
|
|
765
|
-
...validation.
|
|
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
|
-
|
|
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
|
-
|
|
782
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
|
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
|
|
874
|
-
|
|
875
|
-
|
|
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
|
-
//
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
944
|
-
startAutoChange(isIncrement, event);
|
|
939
|
+
startAutoChange(event.nativeEvent);
|
|
945
940
|
} else {
|
|
946
|
-
//
|
|
947
|
-
//
|
|
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
|
|
955
|
-
startAutoChange(
|
|
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
|
|
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
|
|
968
|
-
// so the delayed auto-change check won
|
|
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
|
-
|
|
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
|
-
} =
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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
|
|
1030
|
+
disabled,
|
|
1034
1031
|
formatOptionsRef,
|
|
1035
1032
|
getStepAmount,
|
|
1036
1033
|
id,
|
|
1037
1034
|
incrementValue,
|
|
1038
1035
|
inputRef,
|
|
1039
1036
|
inputValue,
|
|
1040
|
-
|
|
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
|
-
} =
|
|
1055
|
-
const
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
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
|
-
|
|
1081
|
-
|
|
1082
|
-
} =
|
|
1083
|
-
disabled,
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
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
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
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
|
-
*
|
|
1103
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1140
|
-
const disabled = disabledProp || contextDisabled ||
|
|
1180
|
+
const isAtBoundary = value != null && (isIncrement ? value >= maxWithDefault : value <= minWithDefault);
|
|
1181
|
+
const disabled = disabledProp || contextDisabled || isAtBoundary;
|
|
1141
1182
|
const props = useNumberFieldButton({
|
|
1142
|
-
isIncrement
|
|
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 =
|
|
1210
|
+
const buttonState = {
|
|
1172
1211
|
...state,
|
|
1173
1212
|
disabled
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1213
|
+
};
|
|
1214
|
+
return useRenderElement('button', componentProps, {
|
|
1176
1215
|
ref: [forwardedRef, buttonRef],
|
|
1177
1216
|
state: buttonState,
|
|
1178
1217
|
props: [props, elementProps, getButtonProps],
|
|
1179
|
-
stateAttributesMapping
|
|
1218
|
+
stateAttributesMapping
|
|
1180
1219
|
});
|
|
1181
|
-
|
|
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
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
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,
|
|
1254
|
-
const validateOnChange = shouldValidateOnChange();
|
|
1314
|
+
useValueChanged(value, () => {
|
|
1255
1315
|
clearErrors(name);
|
|
1256
|
-
if (
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1325
|
-
const
|
|
1326
|
-
|
|
1327
|
-
|
|
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
|
-
|
|
1332
|
-
|
|
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,
|
|
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(
|
|
1431
|
+
onValueCommitted(committedValue, nextEventDetails);
|
|
1340
1432
|
}
|
|
1341
1433
|
|
|
1342
1434
|
// Normalize only the displayed text
|
|
1343
|
-
const canonicalText = formatNumber(
|
|
1344
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
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 ||
|
|
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
|
-
//
|
|
1438
|
-
|
|
1439
|
-
|
|
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
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
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
|
-
|
|
1460
|
-
|
|
1461
|
-
setValue(min, createChangeEventDetails(keyboard, nativeEvent));
|
|
1462
|
-
|
|
1463
|
-
|
|
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
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
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(
|
|
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(
|
|
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 {
|
|
1609
|
+
export { NumberFieldRoot as N, NumberFieldGroup as a, NumberFieldInput as b, NumberFieldIncrement as c, NumberFieldDecrement as d, stateAttributesMapping as s, useNumberFieldRootContext as u };
|