@octanejs/base-ui 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +33 -0
  3. package/package.json +47 -0
  4. package/src/avatar.ts +308 -0
  5. package/src/checkbox-group.ts +183 -0
  6. package/src/checkbox.ts +578 -0
  7. package/src/field.ts +676 -0
  8. package/src/fieldset.ts +150 -0
  9. package/src/form.ts +183 -0
  10. package/src/index.ts +23 -0
  11. package/src/input.ts +9 -0
  12. package/src/internal.ts +42 -0
  13. package/src/merge-props.ts +2 -0
  14. package/src/meter.ts +204 -0
  15. package/src/number-field.ts +978 -0
  16. package/src/progress.ts +260 -0
  17. package/src/radio-group.ts +285 -0
  18. package/src/radio.ts +401 -0
  19. package/src/separator.ts +43 -0
  20. package/src/slider.ts +1617 -0
  21. package/src/switch.ts +374 -0
  22. package/src/toggle-group.ts +136 -0
  23. package/src/toggle.ts +145 -0
  24. package/src/use-render.ts +40 -0
  25. package/src/utils/CheckboxGroupContext.ts +30 -0
  26. package/src/utils/CompositeRootContext.ts +27 -0
  27. package/src/utils/DirectionContext.ts +15 -0
  28. package/src/utils/RadioGroupContext.ts +27 -0
  29. package/src/utils/ToggleGroupContext.ts +33 -0
  30. package/src/utils/addEventListener.ts +13 -0
  31. package/src/utils/areArraysEqual.ts +11 -0
  32. package/src/utils/clamp.ts +8 -0
  33. package/src/utils/composeRefs.ts +53 -0
  34. package/src/utils/composite/CompositeItem.ts +56 -0
  35. package/src/utils/composite/CompositeList.ts +190 -0
  36. package/src/utils/composite/CompositeListContext.ts +23 -0
  37. package/src/utils/composite/CompositeRoot.ts +132 -0
  38. package/src/utils/composite/keys.ts +179 -0
  39. package/src/utils/composite/list-utils.ts +78 -0
  40. package/src/utils/composite/useCompositeItem.ts +58 -0
  41. package/src/utils/composite/useCompositeListItem.ts +108 -0
  42. package/src/utils/composite/useCompositeRoot.ts +346 -0
  43. package/src/utils/contains.ts +22 -0
  44. package/src/utils/createChangeEventDetails.ts +81 -0
  45. package/src/utils/field/FieldItemContext.ts +13 -0
  46. package/src/utils/field/FieldRootContext.ts +98 -0
  47. package/src/utils/field/FormContext.ts +37 -0
  48. package/src/utils/field/LabelableContext.ts +32 -0
  49. package/src/utils/field/LabelableProvider.ts +108 -0
  50. package/src/utils/field/constants.ts +80 -0
  51. package/src/utils/field/getCombinedFieldValidityData.ts +16 -0
  52. package/src/utils/field/useAriaLabelledBy.ts +86 -0
  53. package/src/utils/field/useFieldControlRegistration.ts +179 -0
  54. package/src/utils/field/useFieldValidation.ts +311 -0
  55. package/src/utils/field/useLabel.ts +95 -0
  56. package/src/utils/field/useLabelableId.ts +99 -0
  57. package/src/utils/field/useRegisterFieldControl.ts +41 -0
  58. package/src/utils/formatNumber.ts +44 -0
  59. package/src/utils/getDefaultFormSubmitter.ts +19 -0
  60. package/src/utils/getElementRef.ts +13 -0
  61. package/src/utils/getStateAttributesProps.ts +34 -0
  62. package/src/utils/matchesFocusVisible.ts +16 -0
  63. package/src/utils/mergeObjects.ts +18 -0
  64. package/src/utils/mergeProps.ts +206 -0
  65. package/src/utils/noop.ts +4 -0
  66. package/src/utils/number/constants.ts +4 -0
  67. package/src/utils/number/parse.ts +227 -0
  68. package/src/utils/number/types.ts +25 -0
  69. package/src/utils/number/validate.ts +129 -0
  70. package/src/utils/owner.ts +9 -0
  71. package/src/utils/platform.ts +15 -0
  72. package/src/utils/resolveAriaLabelledBy.ts +11 -0
  73. package/src/utils/resolveClassName.ts +8 -0
  74. package/src/utils/resolveRef.ts +10 -0
  75. package/src/utils/resolveStyle.ts +10 -0
  76. package/src/utils/serializeValue.ts +15 -0
  77. package/src/utils/slider/asc.ts +4 -0
  78. package/src/utils/slider/getMidpoint.ts +14 -0
  79. package/src/utils/slider/getPushedThumbValues.ts +73 -0
  80. package/src/utils/slider/getSliderValue.ts +27 -0
  81. package/src/utils/slider/replaceArrayItemAtIndex.ts +8 -0
  82. package/src/utils/slider/resolveThumbCollision.ts +178 -0
  83. package/src/utils/slider/roundValueToStep.ts +22 -0
  84. package/src/utils/slider/validateMinimumDistance.ts +22 -0
  85. package/src/utils/slider/valueArrayToPercentages.ts +11 -0
  86. package/src/utils/stringifyLocale.ts +10 -0
  87. package/src/utils/useAnimationFrame.ts +59 -0
  88. package/src/utils/useAnimationsFinished.ts +100 -0
  89. package/src/utils/useBaseUiId.ts +19 -0
  90. package/src/utils/useButton.ts +217 -0
  91. package/src/utils/useCheckboxGroupParent.ts +113 -0
  92. package/src/utils/useControlled.ts +42 -0
  93. package/src/utils/useFocusableWhenDisabled.ts +84 -0
  94. package/src/utils/useForcedRerendering.ts +11 -0
  95. package/src/utils/useIsHydrating.ts +9 -0
  96. package/src/utils/useOpenChangeComplete.ts +46 -0
  97. package/src/utils/usePressAndHold.ts +35 -0
  98. package/src/utils/useRefWithInit.ts +23 -0
  99. package/src/utils/useRegisteredLabelId.ts +32 -0
  100. package/src/utils/useRenderElement.ts +165 -0
  101. package/src/utils/useStableCallback.ts +26 -0
  102. package/src/utils/useTimeout.ts +51 -0
  103. package/src/utils/useTransitionStatus.ts +115 -0
  104. package/src/utils/useValueAsRef.ts +25 -0
  105. package/src/utils/useValueChanged.ts +37 -0
  106. package/src/utils/valueToPercent.ts +4 -0
  107. package/src/utils/visuallyHidden.ts +24 -0
@@ -0,0 +1,978 @@
1
+ // Ported from .base-ui/packages/react/src/number-field/ (v1.6.0): root/NumberFieldRoot (+ its
2
+ // context), group/NumberFieldGroup, input/NumberFieldInput, increment/NumberFieldIncrement,
3
+ // decrement/NumberFieldDecrement, root/useNumberFieldButton + useNumberFieldStepperButton, and
4
+ // utils/stateAttributesMapping — plus its `index.parts` (the `NumberField` namespace).
5
+ //
6
+ // octane adaptations: native events (no `.nativeEvent`); forwardRef → ref-as-prop; the
7
+ // text-input value adaptation (initial value → the `value` ATTRIBUTE; the live value driven via
8
+ // the `.value` PROPERTY). The scrub area + press-and-hold auto-repeat are deferred (see
9
+ // `usePressAndHold` stub). Field/Form integration is inert when standalone.
10
+ import {
11
+ createContext,
12
+ createElement,
13
+ useContext,
14
+ useEffect,
15
+ useLayoutEffect,
16
+ useMemo,
17
+ useRef,
18
+ useState,
19
+ } from 'octane';
20
+
21
+ import { S, subSlot } from './internal';
22
+ import { useRenderElement, type RenderProp } from './utils/useRenderElement';
23
+ import type { StateAttributesMapping } from './utils/getStateAttributesProps';
24
+ import { useComposedRefs } from './utils/composeRefs';
25
+ import { useControlled } from './utils/useControlled';
26
+ import { useStableCallback } from './utils/useStableCallback';
27
+ import { useValueAsRef } from './utils/useValueAsRef';
28
+ import { useValueChanged } from './utils/useValueChanged';
29
+ import { useForcedRerendering } from './utils/useForcedRerendering';
30
+ import { addEventListener } from './utils/addEventListener';
31
+ import { platform } from './utils/platform';
32
+ import { ownerDocument, ownerWindow } from './utils/owner';
33
+ import { visuallyHidden, visuallyHiddenInput } from './utils/visuallyHidden';
34
+ import { stopEvent } from './utils/composite/list-utils';
35
+ import { useButton } from './utils/useButton';
36
+ import { useLabelableId } from './utils/field/useLabelableId';
37
+ import { formatNumber } from './utils/formatNumber';
38
+ import { fieldValidityMapping, type FieldRootState } from './utils/field/constants';
39
+ import { useFieldRootContext } from './utils/field/FieldRootContext';
40
+ import { useFormContext } from './utils/field/FormContext';
41
+ import { useLabelableContext } from './utils/field/LabelableContext';
42
+ import { useRegisterFieldControl } from './utils/field/useRegisterFieldControl';
43
+ import { createChangeEventDetails, REASONS } from './utils/createChangeEventDetails';
44
+ import {
45
+ getNumberLocaleDetails,
46
+ isNumeralChar,
47
+ parseNumber,
48
+ ANY_MINUS_RE,
49
+ ANY_PLUS_RE,
50
+ ANY_MINUS_DETECT_RE,
51
+ ANY_PLUS_DETECT_RE,
52
+ PERCENTAGES,
53
+ PERMILLE,
54
+ SPACE_SEPARATOR_RE,
55
+ BASE_NON_NUMERIC_SYMBOLS,
56
+ MINUS_SIGNS_WITH_ASCII,
57
+ PLUS_SIGNS_WITH_ASCII,
58
+ } from './utils/number/parse';
59
+ import {
60
+ hasNumberFormatRoundingOptions,
61
+ removeFloatingPointErrors,
62
+ toValidatedNumber,
63
+ } from './utils/number/validate';
64
+ import { usePressAndHold, isTouchLikePointerType } from './utils/usePressAndHold';
65
+ import {
66
+ CHANGE_VALUE_TICK_DELAY,
67
+ START_AUTO_CHANGE_DELAY,
68
+ SCROLLING_POINTER_MOVE_DISTANCE,
69
+ } from './utils/number/constants';
70
+ import type { EventWithOptionalKeyState, IncrementValueParameters } from './utils/number/types';
71
+
72
+ type InputMode = 'numeric' | 'decimal' | 'text';
73
+
74
+ const NAVIGATE_KEYS = new Set([
75
+ 'Backspace',
76
+ 'Delete',
77
+ 'ArrowLeft',
78
+ 'ArrowRight',
79
+ 'Tab',
80
+ 'Enter',
81
+ 'Escape',
82
+ ]);
83
+
84
+ function activeElement(doc: Document): Element | null {
85
+ return doc.activeElement;
86
+ }
87
+
88
+ export interface NumberFieldRootState extends FieldRootState {
89
+ value: number | null;
90
+ inputValue: string;
91
+ readOnly: boolean;
92
+ required: boolean;
93
+ scrubbing: boolean;
94
+ }
95
+
96
+ const stateAttributesMapping: StateAttributesMapping<any> = {
97
+ inputValue: () => null,
98
+ value: () => null,
99
+ ...(fieldValidityMapping as StateAttributesMapping<any>),
100
+ };
101
+
102
+ // --- Context -----------------------------------------------------------------
103
+
104
+ const NumberFieldRootContext = createContext<any>(undefined);
105
+
106
+ function useNumberFieldRootContext(): any {
107
+ const context = useContext(NumberFieldRootContext);
108
+ if (context === undefined) {
109
+ throw new Error(
110
+ 'Base UI: NumberFieldRootContext is missing. NumberField parts must be placed within <NumberField.Root>.',
111
+ );
112
+ }
113
+ return context;
114
+ }
115
+
116
+ // --- Root --------------------------------------------------------------------
117
+
118
+ function NumberFieldRoot(props: any): any {
119
+ const slot = S('NumberFieldRoot');
120
+ const {
121
+ id: idProp,
122
+ min,
123
+ max,
124
+ smallStep = 0.1,
125
+ step: stepProp = 1,
126
+ largeStep = 10,
127
+ required = false,
128
+ disabled: disabledProp = false,
129
+ readOnly = false,
130
+ form,
131
+ name: nameProp,
132
+ defaultValue,
133
+ value: valueProp,
134
+ onValueChange: onValueChangeProp,
135
+ onValueCommitted: onValueCommittedProp,
136
+ allowWheelScrub = false,
137
+ snapOnStep = false,
138
+ allowOutOfRange = false,
139
+ format,
140
+ locale,
141
+ render,
142
+ className,
143
+ inputRef: inputRefProp,
144
+ style,
145
+ ref,
146
+ ...elementProps
147
+ } = props;
148
+
149
+ const {
150
+ setDirty,
151
+ validityData,
152
+ disabled: fieldDisabled,
153
+ setFilled,
154
+ invalid,
155
+ name: fieldName,
156
+ state: fieldState,
157
+ validation,
158
+ } = useFieldRootContext();
159
+ const { clearErrors } = useFormContext();
160
+
161
+ const disabled = fieldDisabled || disabledProp;
162
+ const name = fieldName ?? nameProp;
163
+ const step = stepProp === 'any' ? 1 : stepProp;
164
+
165
+ const [isScrubbing, setIsScrubbing] = useState(false, subSlot(slot, 'scrubbing'));
166
+
167
+ const minWithDefault = min ?? Number.MIN_SAFE_INTEGER;
168
+ const maxWithDefault = max ?? Number.MAX_SAFE_INTEGER;
169
+ const minWithZeroDefault = min ?? 0;
170
+ const formatStyle = format?.style;
171
+
172
+ const inputRef = useRef<HTMLInputElement | null>(null, subSlot(slot, 'inputRef'));
173
+ const hiddenInputRef = useComposedRefs(
174
+ inputRefProp,
175
+ validation.inputRef,
176
+ subSlot(slot, 'hiddenRef'),
177
+ );
178
+
179
+ const id = useLabelableId({ id: idProp }, subSlot(slot, 'id'));
180
+
181
+ const [valueUnwrapped, setValueUnwrapped] = useControlled<number | null>(
182
+ { controlled: valueProp, default: defaultValue, name: 'NumberField', state: 'value' },
183
+ subSlot(slot, 'value'),
184
+ );
185
+
186
+ const value = valueUnwrapped ?? null;
187
+ const valueRef = useValueAsRef<number | null>(value, subSlot(slot, 'valueRef'));
188
+
189
+ useLayoutEffect(() => setFilled(value !== null), [setFilled, value], subSlot(slot, 'e:filled'));
190
+
191
+ const forceRender = useForcedRerendering(subSlot(slot, 'force'));
192
+ const formatOptionsRef = useValueAsRef<Intl.NumberFormatOptions | undefined>(
193
+ format,
194
+ subSlot(slot, 'fmtRef'),
195
+ );
196
+ const hasPendingCommitRef = useRef(false, subSlot(slot, 'pending'));
197
+
198
+ const onValueCommitted = useStableCallback(
199
+ (nextValue: number | null, eventDetails: any) => {
200
+ hasPendingCommitRef.current = false;
201
+ onValueCommittedProp?.(nextValue, eventDetails);
202
+ },
203
+ subSlot(slot, 'commit'),
204
+ );
205
+
206
+ const allowInputSyncRef = useRef(true, subSlot(slot, 'allowSync'));
207
+ const lastChangedValueRef = useRef<number | null>(null, subSlot(slot, 'lastChanged'));
208
+
209
+ const [inputValue, setInputValue] = useState(
210
+ () => formatNumber(value, locale, format),
211
+ subSlot(slot, 'inputValue'),
212
+ );
213
+ const [inputMode, setInputMode] = useState<InputMode>('numeric', subSlot(slot, 'inputMode'));
214
+
215
+ // octane: reflect the INITIAL numeric value as the hidden input's `value` ATTRIBUTE.
216
+ const initialHiddenValueRef = useRef(value ?? '', subSlot(slot, 'initialHidden'));
217
+
218
+ const getAllowedNonNumericKeys = useStableCallback(
219
+ () => {
220
+ const { decimal, group, currency, literal } = getNumberLocaleDetails(locale, format);
221
+ const keys = new Set<string>();
222
+ BASE_NON_NUMERIC_SYMBOLS.forEach((symbol) => keys.add(symbol));
223
+ if (decimal) keys.add(decimal);
224
+ if (group) {
225
+ keys.add(group);
226
+ if (SPACE_SEPARATOR_RE.test(group)) keys.add(' ');
227
+ }
228
+ const allowPercentSymbols =
229
+ formatStyle === 'percent' || (formatStyle === 'unit' && format?.unit === 'percent');
230
+ const allowPermilleSymbols =
231
+ formatStyle === 'percent' || (formatStyle === 'unit' && format?.unit === 'permille');
232
+ if (allowPercentSymbols) PERCENTAGES.forEach((key) => keys.add(key));
233
+ if (allowPermilleSymbols) PERMILLE.forEach((key) => keys.add(key));
234
+ if (formatStyle === 'currency' && currency) keys.add(currency);
235
+ if (literal) {
236
+ Array.from(literal).forEach((char) => keys.add(char));
237
+ if (SPACE_SEPARATOR_RE.test(literal)) keys.add(' ');
238
+ }
239
+ PLUS_SIGNS_WITH_ASCII.forEach((key) => keys.add(key));
240
+ if (minWithDefault < 0 || allowOutOfRange) {
241
+ MINUS_SIGNS_WITH_ASCII.forEach((key) => keys.add(key));
242
+ }
243
+ return keys;
244
+ },
245
+ subSlot(slot, 'allowedKeys'),
246
+ );
247
+
248
+ const getStepAmount = useStableCallback(
249
+ (event?: EventWithOptionalKeyState) => {
250
+ if (event?.altKey) return smallStep;
251
+ if (event?.shiftKey) return largeStep;
252
+ return step;
253
+ },
254
+ subSlot(slot, 'stepAmount'),
255
+ );
256
+
257
+ const setValue = useStableCallback(
258
+ (unvalidatedValue: number | null, details: any): boolean => {
259
+ const eventWithOptionalKeyState = details.event as EventWithOptionalKeyState;
260
+ const dir = details.direction;
261
+ const isInputReason =
262
+ details.reason === REASONS.inputChange ||
263
+ details.reason === 'input-clear' ||
264
+ details.reason === 'input-blur' ||
265
+ details.reason === 'input-paste' ||
266
+ details.reason === REASONS.none;
267
+ const shouldClampValue = !allowOutOfRange || !isInputReason;
268
+
269
+ const validatedValue = toValidatedNumber(unvalidatedValue, {
270
+ step: dir ? getStepAmount(eventWithOptionalKeyState) * dir : undefined,
271
+ format: formatOptionsRef.current,
272
+ minWithDefault,
273
+ maxWithDefault,
274
+ minWithZeroDefault,
275
+ snapOnStep,
276
+ small: eventWithOptionalKeyState?.altKey ?? false,
277
+ clamp: shouldClampValue,
278
+ });
279
+
280
+ const shouldFireChange =
281
+ validatedValue !== value ||
282
+ (isInputReason && (unvalidatedValue !== value || allowInputSyncRef.current === false));
283
+
284
+ if (shouldFireChange) {
285
+ onValueChangeProp?.(validatedValue, details);
286
+ if (details.isCanceled) return false;
287
+ setValueUnwrapped(validatedValue);
288
+ setDirty(validatedValue !== validityData.initialValue);
289
+ hasPendingCommitRef.current = true;
290
+ }
291
+
292
+ lastChangedValueRef.current = validatedValue;
293
+ if (allowInputSyncRef.current) {
294
+ setInputValue(formatNumber(validatedValue, locale, format));
295
+ }
296
+ forceRender();
297
+ return shouldFireChange;
298
+ },
299
+ subSlot(slot, 'setValue'),
300
+ );
301
+
302
+ const incrementValue = useStableCallback(
303
+ (amount: number, params: IncrementValueParameters) => {
304
+ const { direction, currentValue, event, reason } = params;
305
+ const prevValue = currentValue == null ? valueRef.current : currentValue;
306
+ if (typeof prevValue !== 'number') {
307
+ return setValue(0, createChangeEventDetails(reason, event));
308
+ }
309
+ return setValue(
310
+ prevValue + amount * direction,
311
+ createChangeEventDetails(reason, event, undefined, { direction }),
312
+ );
313
+ },
314
+ subSlot(slot, 'increment'),
315
+ );
316
+
317
+ // Sync the formatted input value when the external value changes (blur-gated).
318
+ useLayoutEffect(
319
+ () => {
320
+ if (!allowInputSyncRef.current) return;
321
+ const nextInputValue = formatNumber(value, locale, format);
322
+ if (nextInputValue !== inputValue) setInputValue(nextInputValue);
323
+ },
324
+ undefined,
325
+ subSlot(slot, 'e:syncInput'),
326
+ );
327
+
328
+ useLayoutEffect(
329
+ () => {
330
+ if (!platform.os.ios) return;
331
+ let computedInputMode: InputMode = 'text';
332
+ if (minWithDefault >= 0) computedInputMode = 'decimal';
333
+ setInputMode(computedInputMode);
334
+ },
335
+ [minWithDefault],
336
+ subSlot(slot, 'e:inputMode'),
337
+ );
338
+
339
+ useEffect(
340
+ () => {
341
+ const element = inputRef.current;
342
+ if (disabled || readOnly || !allowWheelScrub || !element) return undefined;
343
+ function handleWheel(event: WheelEvent) {
344
+ if (event.ctrlKey || activeElement(ownerDocument(inputRef.current)) !== inputRef.current) {
345
+ return;
346
+ }
347
+ event.preventDefault();
348
+ allowInputSyncRef.current = true;
349
+ const amount = getStepAmount(event);
350
+ const changed = incrementValue(amount, {
351
+ direction: event.deltaY > 0 ? -1 : 1,
352
+ event,
353
+ reason: 'wheel',
354
+ });
355
+ if (changed) {
356
+ onValueCommitted(
357
+ lastChangedValueRef.current ?? valueRef.current,
358
+ createChangeEventDetails('wheel' as any, event),
359
+ );
360
+ }
361
+ }
362
+ return addEventListener(element, 'wheel', handleWheel as EventListener);
363
+ },
364
+ [allowWheelScrub, incrementValue, disabled, readOnly, getStepAmount, onValueCommitted],
365
+ subSlot(slot, 'e:wheel'),
366
+ );
367
+
368
+ const state: NumberFieldRootState = useMemo(
369
+ () => ({
370
+ ...fieldState,
371
+ disabled,
372
+ readOnly,
373
+ required,
374
+ value,
375
+ inputValue,
376
+ scrubbing: isScrubbing,
377
+ }),
378
+ [fieldState, disabled, readOnly, required, value, inputValue, isScrubbing],
379
+ subSlot(slot, 'state'),
380
+ );
381
+
382
+ const contextValue = useMemo(
383
+ () => ({
384
+ inputRef,
385
+ inputValue,
386
+ value,
387
+ minWithDefault,
388
+ maxWithDefault,
389
+ disabled,
390
+ readOnly,
391
+ id,
392
+ setValue,
393
+ incrementValue,
394
+ getStepAmount,
395
+ allowInputSyncRef,
396
+ formatOptionsRef,
397
+ valueRef,
398
+ lastChangedValueRef,
399
+ hasPendingCommitRef,
400
+ name,
401
+ nameProp,
402
+ required,
403
+ invalid,
404
+ inputMode,
405
+ getAllowedNonNumericKeys,
406
+ min,
407
+ max,
408
+ setInputValue,
409
+ locale,
410
+ isScrubbing,
411
+ setIsScrubbing,
412
+ state,
413
+ onValueCommitted,
414
+ }),
415
+ [
416
+ inputValue,
417
+ value,
418
+ minWithDefault,
419
+ maxWithDefault,
420
+ disabled,
421
+ readOnly,
422
+ id,
423
+ setValue,
424
+ incrementValue,
425
+ getStepAmount,
426
+ name,
427
+ nameProp,
428
+ required,
429
+ invalid,
430
+ inputMode,
431
+ getAllowedNonNumericKeys,
432
+ min,
433
+ max,
434
+ locale,
435
+ isScrubbing,
436
+ state,
437
+ onValueCommitted,
438
+ ],
439
+ subSlot(slot, 'ctx'),
440
+ );
441
+
442
+ const element = useRenderElement(
443
+ 'div',
444
+ { render, className, style },
445
+ { ref, state, props: elementProps, stateAttributesMapping },
446
+ subSlot(slot, 're'),
447
+ );
448
+
449
+ const hiddenInputProps = validation.getValidationProps(disabled, {
450
+ onFocus() {
451
+ inputRef.current?.focus();
452
+ },
453
+ onChange(event: any) {
454
+ if (event.defaultPrevented || disabled || readOnly) return;
455
+ const nextValue = event.currentTarget.valueAsNumber;
456
+ const parsedValue = Number.isNaN(nextValue) ? null : nextValue;
457
+ const details = createChangeEventDetails(REASONS.none, event);
458
+ setValue(parsedValue, details);
459
+ clearErrors(name);
460
+ validation.change(lastChangedValueRef.current ?? parsedValue);
461
+ },
462
+ });
463
+
464
+ const hiddenInput = createElement('input', {
465
+ ...hiddenInputProps,
466
+ ref: hiddenInputRef,
467
+ type: 'number',
468
+ form,
469
+ name,
470
+ // octane: initial value → attribute; live value driven via the property (sync effect below).
471
+ value: initialHiddenValueRef.current,
472
+ min,
473
+ max,
474
+ step: stepProp,
475
+ disabled,
476
+ readOnly,
477
+ required,
478
+ 'aria-hidden': true,
479
+ tabIndex: -1,
480
+ style: name ? visuallyHiddenInput : visuallyHidden,
481
+ });
482
+
483
+ // octane: drive the hidden input's live `.value` PROPERTY.
484
+ useLayoutEffect(
485
+ () => {
486
+ const el = validation.inputRef.current ?? inputRef.current;
487
+ if (!el) return;
488
+ const setNativeValue = Object.getOwnPropertyDescriptor(
489
+ ownerWindow(el).HTMLInputElement.prototype,
490
+ 'value',
491
+ )?.set;
492
+ setNativeValue?.call(el, value ?? '');
493
+ },
494
+ [value],
495
+ subSlot(slot, 'e:syncHidden'),
496
+ );
497
+
498
+ return createElement(NumberFieldRootContext.Provider, {
499
+ value: contextValue,
500
+ children: [element, hiddenInput],
501
+ });
502
+ }
503
+
504
+ // --- Group -------------------------------------------------------------------
505
+
506
+ function NumberFieldGroup(props: any): any {
507
+ const slot = S('NumberFieldGroup');
508
+ const { render, className, style, ref, ...elementProps } = props;
509
+ const { state } = useNumberFieldRootContext();
510
+ return useRenderElement(
511
+ 'div',
512
+ { render, className, style },
513
+ { ref, state, props: [{ role: 'group' }, elementProps], stateAttributesMapping },
514
+ subSlot(slot, 're'),
515
+ );
516
+ }
517
+
518
+ // --- Input -------------------------------------------------------------------
519
+
520
+ function NumberFieldInput(props: any): any {
521
+ const slot = S('NumberFieldInput');
522
+ const { render, className, style, ref, ...elementProps } = props;
523
+
524
+ const {
525
+ allowInputSyncRef,
526
+ disabled,
527
+ formatOptionsRef,
528
+ getAllowedNonNumericKeys,
529
+ getStepAmount,
530
+ id,
531
+ incrementValue,
532
+ inputMode,
533
+ inputValue,
534
+ max,
535
+ min,
536
+ name,
537
+ nameProp,
538
+ readOnly,
539
+ required,
540
+ setValue,
541
+ state,
542
+ setInputValue,
543
+ locale,
544
+ inputRef,
545
+ value,
546
+ onValueCommitted,
547
+ lastChangedValueRef,
548
+ hasPendingCommitRef,
549
+ valueRef,
550
+ } = useNumberFieldRootContext();
551
+
552
+ const { clearErrors } = useFormContext();
553
+ const { validationMode, setTouched, setFocused, invalid, shouldValidateOnChange, validation } =
554
+ useFieldRootContext();
555
+ const { labelId } = useLabelableContext();
556
+
557
+ const hasTouchedInputRef = useRef(false, subSlot(slot, 'touched'));
558
+ const blockRevalidationRef = useRef(false, subSlot(slot, 'blockReval'));
559
+ const pendingCaretRef = useRef<number | null>(null, subSlot(slot, 'caret'));
560
+
561
+ // octane: reflect the INITIAL formatted text as the `value` ATTRIBUTE; live via the property.
562
+ const initialInputValueRef = useRef(inputValue, subSlot(slot, 'initialInput'));
563
+
564
+ useRegisterFieldControl(
565
+ inputRef,
566
+ id,
567
+ value,
568
+ undefined,
569
+ !disabled,
570
+ nameProp,
571
+ subSlot(slot, 'register'),
572
+ );
573
+
574
+ useLayoutEffect(
575
+ () => {
576
+ if (pendingCaretRef.current != null) {
577
+ const caret = pendingCaretRef.current;
578
+ pendingCaretRef.current = null;
579
+ inputRef.current?.setSelectionRange(caret, caret);
580
+ }
581
+ },
582
+ undefined,
583
+ subSlot(slot, 'e:caret'),
584
+ );
585
+
586
+ // octane: drive the visible input's live `.value` PROPERTY (matches React's controlled input).
587
+ useLayoutEffect(
588
+ () => {
589
+ const el = inputRef.current;
590
+ if (!el) return;
591
+ const setNativeValue = Object.getOwnPropertyDescriptor(
592
+ ownerWindow(el).HTMLInputElement.prototype,
593
+ 'value',
594
+ )?.set;
595
+ setNativeValue?.call(el, inputValue);
596
+ },
597
+ [inputValue],
598
+ subSlot(slot, 'e:syncValue'),
599
+ );
600
+
601
+ useValueChanged(
602
+ value,
603
+ () => {
604
+ clearErrors(name);
605
+ if (blockRevalidationRef.current && !shouldValidateOnChange()) {
606
+ blockRevalidationRef.current = false;
607
+ return;
608
+ }
609
+ validation.change(value);
610
+ },
611
+ subSlot(slot, 'valueChanged'),
612
+ );
613
+
614
+ const inputProps: Record<string, any> = {
615
+ id,
616
+ required,
617
+ disabled,
618
+ readOnly,
619
+ inputMode,
620
+ // octane: initial formatted text → attribute; live value via the property (sync effect above).
621
+ value: initialInputValueRef.current,
622
+ type: 'text',
623
+ autoComplete: 'off',
624
+ autoCorrect: 'off',
625
+ spellCheck: 'false',
626
+ 'aria-roledescription': 'Number field',
627
+ 'aria-invalid': !disabled && invalid ? true : undefined,
628
+ 'aria-labelledby': labelId,
629
+ onFocus(event: any) {
630
+ if (event.defaultPrevented || disabled) return;
631
+ setFocused(true);
632
+ if (hasTouchedInputRef.current) return;
633
+ hasTouchedInputRef.current = true;
634
+ const target = event.currentTarget;
635
+ const length = target.value.length;
636
+ target.setSelectionRange(length, length);
637
+ },
638
+ onBlur(event: any) {
639
+ if (event.defaultPrevented || disabled) return;
640
+ setTouched(true);
641
+ setFocused(false);
642
+ if (readOnly) return;
643
+ const hadManualInput = !allowInputSyncRef.current;
644
+ const hadPendingProgrammaticChange = hasPendingCommitRef.current;
645
+ allowInputSyncRef.current = true;
646
+ if (inputValue.trim() === '') {
647
+ const clearDetails = createChangeEventDetails('input-clear' as any, event);
648
+ setValue(null, clearDetails);
649
+ if (clearDetails.isCanceled) return;
650
+ if (validationMode === 'onBlur') validation.commit(null);
651
+ if (hadManualInput || hadPendingProgrammaticChange || value !== null) {
652
+ onValueCommitted(null, createChangeEventDetails('input-clear' as any, event));
653
+ }
654
+ return;
655
+ }
656
+ const formatOptions = formatOptionsRef.current;
657
+ const parsedValue = parseNumber(inputValue, locale, formatOptions);
658
+ if (parsedValue === null) return;
659
+ const hasRoundingOptions = hasNumberFormatRoundingOptions(formatOptions);
660
+ let committed: number | null;
661
+ if (!hadManualInput && !hasRoundingOptions) {
662
+ committed = value;
663
+ } else if (hasRoundingOptions) {
664
+ committed = removeFloatingPointErrors(parsedValue, formatOptions);
665
+ } else {
666
+ committed = parsedValue;
667
+ }
668
+ const shouldUpdateValue = value !== committed;
669
+ const shouldCommit = hadManualInput || shouldUpdateValue || hadPendingProgrammaticChange;
670
+ let committedValue = committed;
671
+ if (shouldUpdateValue) {
672
+ const changeDetails = createChangeEventDetails('input-blur' as any, event);
673
+ blockRevalidationRef.current = true;
674
+ setValue(committed, changeDetails);
675
+ if (changeDetails.isCanceled) {
676
+ blockRevalidationRef.current = false;
677
+ return;
678
+ }
679
+ committedValue = lastChangedValueRef.current ?? committed;
680
+ if (committedValue === value) blockRevalidationRef.current = false;
681
+ }
682
+ if (validationMode === 'onBlur') validation.commit(committedValue);
683
+ if (shouldCommit)
684
+ onValueCommitted(committedValue, createChangeEventDetails('input-blur' as any, event));
685
+ const canonicalText = formatNumber(committedValue, locale, formatOptions);
686
+ if (inputValue !== canonicalText) setInputValue(canonicalText);
687
+ },
688
+ onChange(event: any) {
689
+ if (event.defaultPrevented) return;
690
+ allowInputSyncRef.current = false;
691
+ const targetValue = event.currentTarget.value;
692
+ if (targetValue.trim() === '') {
693
+ setInputValue(targetValue);
694
+ setValue(null, createChangeEventDetails('input-clear' as any, event));
695
+ return;
696
+ }
697
+ const allowedNonNumericKeys = getAllowedNonNumericKeys();
698
+ const isValidCharacterString = Array.from(targetValue as string).every(
699
+ (ch) => isNumeralChar(ch) || ANY_MINUS_DETECT_RE.test(ch) || allowedNonNumericKeys.has(ch),
700
+ );
701
+ if (!isValidCharacterString) return;
702
+ const parsedValue = parseNumber(targetValue, locale, formatOptionsRef.current);
703
+ setInputValue(targetValue);
704
+ if (parsedValue !== null) {
705
+ setValue(parsedValue, createChangeEventDetails(REASONS.inputChange, event));
706
+ }
707
+ },
708
+ onKeyDown(event: any) {
709
+ if (event.defaultPrevented || readOnly || disabled) return;
710
+ const nativeEvent = event;
711
+ const hadManualInput = !allowInputSyncRef.current;
712
+ const allowedNonNumericKeys = getAllowedNonNumericKeys();
713
+ let isAllowedNonNumericKey = allowedNonNumericKeys.has(event.key);
714
+ const { decimal, currency, percentSign } = getNumberLocaleDetails(
715
+ locale,
716
+ formatOptionsRef.current,
717
+ );
718
+ const selectionStart = event.currentTarget.selectionStart;
719
+ const selectionEnd = event.currentTarget.selectionEnd;
720
+ const isAllSelected = selectionStart === 0 && selectionEnd === inputValue.length;
721
+ const selectionContainsIndex = (index: number) =>
722
+ selectionStart != null &&
723
+ selectionEnd != null &&
724
+ index >= selectionStart &&
725
+ index < selectionEnd;
726
+
727
+ const signGroups = [
728
+ [ANY_MINUS_DETECT_RE, ANY_MINUS_RE],
729
+ [ANY_PLUS_DETECT_RE, ANY_PLUS_RE],
730
+ ] as const;
731
+ signGroups.forEach(([detectRe, globalRe]) => {
732
+ if (
733
+ detectRe.test(event.key) &&
734
+ Array.from(allowedNonNumericKeys as Set<string>).some((k) => detectRe.test(k))
735
+ ) {
736
+ const existingIndex = inputValue.search(globalRe);
737
+ const isReplacingExisting = existingIndex !== -1 && selectionContainsIndex(existingIndex);
738
+ isAllowedNonNumericKey =
739
+ !(ANY_MINUS_DETECT_RE.test(inputValue) || ANY_PLUS_DETECT_RE.test(inputValue)) ||
740
+ isAllSelected ||
741
+ isReplacingExisting;
742
+ }
743
+ });
744
+ [decimal, currency, percentSign].forEach((symbol) => {
745
+ if (symbol && event.key === symbol) {
746
+ const symbolIndex = inputValue.indexOf(symbol);
747
+ const isSymbolHighlighted = selectionContainsIndex(symbolIndex);
748
+ isAllowedNonNumericKey =
749
+ !inputValue.includes(symbol) || isAllSelected || isSymbolHighlighted;
750
+ }
751
+ });
752
+
753
+ const isNavigateKey = NAVIGATE_KEYS.has(event.key);
754
+ const isStepKey = event.key === 'ArrowUp' || event.key === 'ArrowDown';
755
+ if (
756
+ event.which === 229 ||
757
+ (event.altKey && !isStepKey) ||
758
+ event.ctrlKey ||
759
+ event.metaKey ||
760
+ isAllowedNonNumericKey ||
761
+ isNumeralChar(event.key) ||
762
+ isNavigateKey
763
+ ) {
764
+ return;
765
+ }
766
+
767
+ const willSetHome = event.key === 'Home' && min != null;
768
+ const willSetEnd = event.key === 'End' && max != null;
769
+ if (event.key.length > 1 && !isStepKey && !willSetHome && !willSetEnd) return;
770
+
771
+ const currentValue = hadManualInput
772
+ ? parseNumber(inputValue, locale, formatOptionsRef.current)
773
+ : null;
774
+ const amount = getStepAmount(event);
775
+ stopEvent(event);
776
+ const commitDetails = createChangeEventDetails('keyboard' as any, nativeEvent);
777
+ let changed = false;
778
+ if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
779
+ allowInputSyncRef.current = true;
780
+ if (!hadManualInput) lastChangedValueRef.current = valueRef.current;
781
+ changed = incrementValue(amount, {
782
+ direction: event.key === 'ArrowUp' ? 1 : -1,
783
+ currentValue,
784
+ event: nativeEvent,
785
+ reason: 'keyboard',
786
+ });
787
+ } else if (willSetHome) {
788
+ allowInputSyncRef.current = true;
789
+ changed = setValue(min, createChangeEventDetails('keyboard' as any, nativeEvent));
790
+ } else if (willSetEnd) {
791
+ allowInputSyncRef.current = true;
792
+ changed = setValue(max, createChangeEventDetails('keyboard' as any, nativeEvent));
793
+ }
794
+ if (changed) onValueCommitted(lastChangedValueRef.current ?? valueRef.current, commitDetails);
795
+ },
796
+ onPaste(event: any) {
797
+ if (event.defaultPrevented || readOnly || disabled) return;
798
+ let pastedData = '';
799
+ try {
800
+ pastedData = event.clipboardData?.getData('text/plain') ?? '';
801
+ } catch {
802
+ return;
803
+ }
804
+ event.preventDefault();
805
+ const input = event.currentTarget;
806
+ const selectionStart = input.selectionStart ?? inputValue.length;
807
+ const selectionEnd = input.selectionEnd ?? inputValue.length;
808
+ const nextText =
809
+ inputValue.slice(0, selectionStart) + pastedData + inputValue.slice(selectionEnd);
810
+ const parsedValue = parseNumber(nextText, locale, formatOptionsRef.current);
811
+ if (parsedValue !== null) {
812
+ allowInputSyncRef.current = false;
813
+ pendingCaretRef.current = selectionStart + pastedData.length;
814
+ setValue(parsedValue, createChangeEventDetails('input-paste' as any, event));
815
+ setInputValue(nextText);
816
+ }
817
+ },
818
+ };
819
+
820
+ return useRenderElement(
821
+ 'input',
822
+ { render, className, style },
823
+ {
824
+ ref: [ref, inputRef],
825
+ state,
826
+ props: [inputProps, elementProps, (p: any) => validation.getValidationProps(disabled, p)],
827
+ stateAttributesMapping,
828
+ },
829
+ subSlot(slot, 're'),
830
+ );
831
+ }
832
+
833
+ // --- Stepper buttons ---------------------------------------------------------
834
+
835
+ function useNumberFieldButton(params: any): Record<string, any> {
836
+ const {
837
+ allowInputSyncRef,
838
+ disabled,
839
+ formatOptionsRef,
840
+ getStepAmount,
841
+ id,
842
+ incrementValue,
843
+ inputRef,
844
+ inputValue,
845
+ isIncrement,
846
+ locale,
847
+ readOnly,
848
+ setValue,
849
+ valueRef,
850
+ lastChangedValueRef,
851
+ onValueCommitted,
852
+ slot,
853
+ } = params;
854
+
855
+ const pressReason = isIncrement ? REASONS.incrementPress : REASONS.decrementPress;
856
+
857
+ function commitValue(nativeEvent: any) {
858
+ const shouldCommitInputValue = !allowInputSyncRef.current;
859
+ allowInputSyncRef.current = true;
860
+ if (!shouldCommitInputValue) {
861
+ lastChangedValueRef.current = valueRef.current;
862
+ return;
863
+ }
864
+ const parsedValue = parseNumber(inputValue, locale, formatOptionsRef.current);
865
+ if (parsedValue !== null) {
866
+ const details = createChangeEventDetails(pressReason, nativeEvent);
867
+ setValue(parsedValue, details);
868
+ if (!details.isCanceled) valueRef.current = parsedValue;
869
+ }
870
+ }
871
+
872
+ const { pointerHandlers, shouldSkipClick } = usePressAndHold({
873
+ disabled: disabled || readOnly,
874
+ elementRef: inputRef,
875
+ tickDelay: CHANGE_VALUE_TICK_DELAY,
876
+ startDelay: START_AUTO_CHANGE_DELAY,
877
+ scrollDistance: SCROLLING_POINTER_MOVE_DISTANCE,
878
+ tick(triggerEvent?: any) {
879
+ const amount = getStepAmount(triggerEvent);
880
+ return incrementValue(amount, {
881
+ direction: isIncrement ? 1 : -1,
882
+ event: triggerEvent,
883
+ reason: pressReason,
884
+ });
885
+ },
886
+ onStop(nativeEvent: any) {
887
+ const committed = lastChangedValueRef.current ?? valueRef.current;
888
+ onValueCommitted(committed, createChangeEventDetails(pressReason, nativeEvent));
889
+ },
890
+ });
891
+
892
+ return {
893
+ disabled,
894
+ 'aria-label': isIncrement ? 'Increase' : 'Decrease',
895
+ 'aria-controls': id,
896
+ tabIndex: -1,
897
+ style: { WebkitUserSelect: 'none', userSelect: 'none' },
898
+ ...pointerHandlers,
899
+ onClick(event: any) {
900
+ const isDisabled = disabled || readOnly;
901
+ if (event.defaultPrevented || isDisabled || shouldSkipClick(event)) return;
902
+ commitValue(event);
903
+ const amount = getStepAmount(event);
904
+ const prev = valueRef.current;
905
+ incrementValue(amount, { direction: isIncrement ? 1 : -1, event, reason: pressReason });
906
+ const committed = lastChangedValueRef.current ?? valueRef.current;
907
+ if (committed !== prev) {
908
+ onValueCommitted(committed, createChangeEventDetails(pressReason, event));
909
+ }
910
+ },
911
+ onPointerDown(event: any) {
912
+ const isMainButton = !event.button || event.button === 0;
913
+ if (event.defaultPrevented || readOnly || !isMainButton || disabled) return;
914
+ commitValue(event);
915
+ lastChangedValueRef.current = null;
916
+ if (!isTouchLikePointerType(event.pointerType)) inputRef.current?.focus();
917
+ pointerHandlers.onPointerDown(event);
918
+ },
919
+ _slot: slot,
920
+ };
921
+ }
922
+
923
+ function useNumberFieldStepperButton(componentProps: any, isIncrement: boolean, slot: symbol): any {
924
+ const {
925
+ render,
926
+ className,
927
+ disabled: disabledProp = false,
928
+ nativeButton = true,
929
+ style,
930
+ ref,
931
+ ...elementProps
932
+ } = componentProps;
933
+ const ctx = useNumberFieldRootContext();
934
+ const { disabled: contextDisabled, maxWithDefault, minWithDefault, readOnly, state, value } = ctx;
935
+
936
+ const isAtBoundary =
937
+ value != null && (isIncrement ? value >= maxWithDefault : value <= minWithDefault);
938
+ const disabled = disabledProp || contextDisabled || isAtBoundary;
939
+
940
+ const buttonProps = useNumberFieldButton({ ...ctx, isIncrement, disabled, slot });
941
+
942
+ const { getButtonProps, buttonRef } = useButton(
943
+ { disabled: disabled || readOnly, native: nativeButton, focusableWhenDisabled: true },
944
+ subSlot(slot, 'button'),
945
+ );
946
+
947
+ const buttonState = { ...state, disabled };
948
+
949
+ return useRenderElement(
950
+ 'button',
951
+ { render, className, style },
952
+ {
953
+ ref: [ref, buttonRef],
954
+ state: buttonState,
955
+ props: [buttonProps, elementProps, getButtonProps],
956
+ stateAttributesMapping,
957
+ },
958
+ subSlot(slot, 're'),
959
+ );
960
+ }
961
+
962
+ function NumberFieldIncrement(props: any): any {
963
+ return useNumberFieldStepperButton(props, true, S('NumberFieldIncrement'));
964
+ }
965
+
966
+ function NumberFieldDecrement(props: any): any {
967
+ return useNumberFieldStepperButton(props, false, S('NumberFieldDecrement'));
968
+ }
969
+
970
+ // --- Namespace (mirrors `export * as NumberField`) ---------------------------
971
+
972
+ export const NumberField = {
973
+ Root: NumberFieldRoot,
974
+ Group: NumberFieldGroup,
975
+ Input: NumberFieldInput,
976
+ Increment: NumberFieldIncrement,
977
+ Decrement: NumberFieldDecrement,
978
+ };