@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
|
@@ -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
|
|
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
|
-
|
|
151
|
-
const
|
|
152
|
-
const
|
|
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
|
|
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:
|
|
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) ||
|
|
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
|
|
308
|
+
num = shiftDecimal(num, -3);
|
|
322
309
|
} else if (!isUnitPercent && hasPercentSymbol) {
|
|
323
|
-
num
|
|
310
|
+
num = shiftDecimal(num, -2);
|
|
324
311
|
}
|
|
325
|
-
if (Number.
|
|
312
|
+
if (!Number.isFinite(num)) {
|
|
326
313
|
return null;
|
|
327
314
|
}
|
|
328
315
|
return num;
|
|
329
316
|
}
|
|
330
317
|
|
|
331
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
|
330
|
+
function removeFloatingPointErrors(value, format) {
|
|
349
331
|
if (!Number.isFinite(value)) {
|
|
350
332
|
return value;
|
|
351
333
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
|
369
|
-
const rawSteps = (clampedValue - base + tolerance) / divisor;
|
|
370
|
-
let snappedSteps;
|
|
369
|
+
const rawSteps = value - base + tolerance;
|
|
371
370
|
if (mode === 'nearest') {
|
|
372
|
-
|
|
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
|
|
379
|
-
return base + snappedSteps *
|
|
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
|
-
|
|
395
|
-
if (step != null && snapOnStep) {
|
|
396
|
-
|
|
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
|
-
//
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
const
|
|
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
|
-
//
|
|
572
|
-
//
|
|
573
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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.
|
|
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
|
|
701
|
-
React__namespace.useEffect(() => {
|
|
702
|
-
return () => stopAutoChange();
|
|
703
|
-
}, [stopAutoChange]);
|
|
655
|
+
}, [minWithDefault]);
|
|
704
656
|
|
|
705
|
-
//
|
|
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)
|
|
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
|
-
|
|
721
|
-
|
|
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
|
-
|
|
728
|
-
|
|
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,
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
803
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
|
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
|
|
895
|
-
|
|
896
|
-
|
|
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
|
-
//
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
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
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
965
|
-
startAutoChange(isIncrement, event);
|
|
960
|
+
startAutoChange(event.nativeEvent);
|
|
966
961
|
} else {
|
|
967
|
-
//
|
|
968
|
-
//
|
|
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
|
|
976
|
-
startAutoChange(
|
|
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
|
|
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
|
|
989
|
-
// so the delayed auto-change check won
|
|
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
|
-
|
|
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
|
-
} =
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
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
|
|
1051
|
+
disabled,
|
|
1055
1052
|
formatOptionsRef,
|
|
1056
1053
|
getStepAmount,
|
|
1057
1054
|
id,
|
|
1058
1055
|
incrementValue,
|
|
1059
1056
|
inputRef,
|
|
1060
1057
|
inputValue,
|
|
1061
|
-
|
|
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
|
-
} =
|
|
1076
|
-
const
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
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
|
-
|
|
1102
|
-
|
|
1103
|
-
} =
|
|
1104
|
-
disabled,
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
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
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
-
*
|
|
1124
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1161
|
-
const disabled = disabledProp || contextDisabled ||
|
|
1201
|
+
const isAtBoundary = value != null && (isIncrement ? value >= maxWithDefault : value <= minWithDefault);
|
|
1202
|
+
const disabled = disabledProp || contextDisabled || isAtBoundary;
|
|
1162
1203
|
const props = useNumberFieldButton({
|
|
1163
|
-
isIncrement
|
|
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 =
|
|
1231
|
+
const buttonState = {
|
|
1193
1232
|
...state,
|
|
1194
1233
|
disabled
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1234
|
+
};
|
|
1235
|
+
return useRenderElement.useRenderElement('button', componentProps, {
|
|
1197
1236
|
ref: [forwardedRef, buttonRef],
|
|
1198
1237
|
state: buttonState,
|
|
1199
1238
|
props: [props, elementProps, getButtonProps],
|
|
1200
|
-
stateAttributesMapping
|
|
1239
|
+
stateAttributesMapping
|
|
1201
1240
|
});
|
|
1202
|
-
|
|
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
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
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,
|
|
1275
|
-
const validateOnChange = shouldValidateOnChange();
|
|
1335
|
+
useButton.useValueChanged(value, () => {
|
|
1276
1336
|
clearErrors(name);
|
|
1277
|
-
if (
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1346
|
-
const
|
|
1347
|
-
|
|
1348
|
-
|
|
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
|
-
|
|
1353
|
-
|
|
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,
|
|
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(
|
|
1452
|
+
onValueCommitted(committedValue, nextEventDetails);
|
|
1361
1453
|
}
|
|
1362
1454
|
|
|
1363
1455
|
// Normalize only the displayed text
|
|
1364
|
-
const canonicalText = formatNumber(
|
|
1365
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
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 ||
|
|
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
|
-
//
|
|
1459
|
-
|
|
1460
|
-
|
|
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
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
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
|
-
|
|
1481
|
-
|
|
1482
|
-
setValue(min, useButton.createChangeEventDetails(useButton.keyboard, nativeEvent));
|
|
1483
|
-
|
|
1484
|
-
|
|
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
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
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(
|
|
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(
|
|
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
|
|
1635
|
+
exports.stateAttributesMapping = stateAttributesMapping;
|
|
1523
1636
|
exports.useNumberFieldRootContext = useNumberFieldRootContext;
|