base-ui-vue 0.2.0 → 0.4.0

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 (109) hide show
  1. package/dist/button/ToolbarButton.cjs +6 -0
  2. package/dist/button/ToolbarButton.js +1 -1
  3. package/dist/content/ScrollAreaContent.cjs +168 -0
  4. package/dist/content/ScrollAreaContent.cjs.map +1 -0
  5. package/dist/content/ScrollAreaContent.js +133 -0
  6. package/dist/content/ScrollAreaContent.js.map +1 -0
  7. package/dist/control/SliderControl.js +2 -2
  8. package/dist/corner/ScrollAreaCorner.cjs +77 -0
  9. package/dist/corner/ScrollAreaCorner.cjs.map +1 -0
  10. package/dist/corner/ScrollAreaCorner.js +72 -0
  11. package/dist/corner/ScrollAreaCorner.js.map +1 -0
  12. package/dist/decrement/NumberFieldDecrement.cjs +861 -0
  13. package/dist/decrement/NumberFieldDecrement.cjs.map +1 -0
  14. package/dist/decrement/NumberFieldDecrement.js +700 -0
  15. package/dist/decrement/NumberFieldDecrement.js.map +1 -0
  16. package/dist/fallback/AvatarFallback.cjs +2 -46
  17. package/dist/fallback/AvatarFallback.cjs.map +1 -1
  18. package/dist/fallback/AvatarFallback.js +3 -41
  19. package/dist/fallback/AvatarFallback.js.map +1 -1
  20. package/dist/group/NumberFieldGroup.cjs +72 -0
  21. package/dist/group/NumberFieldGroup.cjs.map +1 -0
  22. package/dist/group/NumberFieldGroup.js +67 -0
  23. package/dist/group/NumberFieldGroup.js.map +1 -0
  24. package/dist/increment/NumberFieldIncrement.cjs +112 -0
  25. package/dist/increment/NumberFieldIncrement.cjs.map +1 -0
  26. package/dist/increment/NumberFieldIncrement.js +107 -0
  27. package/dist/increment/NumberFieldIncrement.js.map +1 -0
  28. package/dist/index.cjs +52 -0
  29. package/dist/index.d.cts +1761 -430
  30. package/dist/index.d.cts.map +1 -1
  31. package/dist/index.d.ts +1761 -430
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +7 -2
  34. package/dist/index2.cjs +4065 -60
  35. package/dist/index2.cjs.map +1 -1
  36. package/dist/index2.js +3955 -184
  37. package/dist/index2.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/index.ts +6 -0
  40. package/src/input/Input.vue +37 -0
  41. package/src/input/InputDataAttributes.ts +30 -0
  42. package/src/input/index.ts +4 -0
  43. package/src/meter/index.ts +16 -0
  44. package/src/meter/indicator/MeterIndicator.vue +65 -0
  45. package/src/meter/label/MeterLabel.vue +63 -0
  46. package/src/meter/root/MeterRoot.vue +131 -0
  47. package/src/meter/root/MeterRootContext.ts +41 -0
  48. package/src/meter/track/MeterTrack.vue +46 -0
  49. package/src/meter/value/MeterValue.vue +85 -0
  50. package/src/number-field/decrement/NumberFieldDecrement.vue +109 -0
  51. package/src/number-field/group/NumberFieldGroup.vue +47 -0
  52. package/src/number-field/increment/NumberFieldIncrement.vue +109 -0
  53. package/src/number-field/index.ts +42 -0
  54. package/src/number-field/input/NumberFieldInput.vue +455 -0
  55. package/src/number-field/root/NumberFieldRoot.vue +626 -0
  56. package/src/number-field/root/NumberFieldRootContext.ts +94 -0
  57. package/src/number-field/root/useNumberFieldButton.ts +171 -0
  58. package/src/number-field/scrub-area/NumberFieldScrubArea.vue +359 -0
  59. package/src/number-field/scrub-area/NumberFieldScrubAreaContext.ts +26 -0
  60. package/src/number-field/scrub-area-cursor/NumberFieldScrubAreaCursor.vue +75 -0
  61. package/src/number-field/utils/constants.ts +4 -0
  62. package/src/number-field/utils/getViewportRect.ts +34 -0
  63. package/src/number-field/utils/parse.ts +248 -0
  64. package/src/number-field/utils/stateAttributesMapping.ts +9 -0
  65. package/src/number-field/utils/subscribeToVisualViewportResize.ts +27 -0
  66. package/src/number-field/utils/types.ts +24 -0
  67. package/src/number-field/utils/validate.ts +120 -0
  68. package/src/otp-field/index.ts +22 -0
  69. package/src/otp-field/input/OtpFieldInput.vue +336 -0
  70. package/src/otp-field/root/OtpFieldRoot.vue +583 -0
  71. package/src/otp-field/root/OtpFieldRootContext.ts +81 -0
  72. package/src/otp-field/utils/otp.ts +135 -0
  73. package/src/otp-field/utils/stateAttributesMapping.ts +16 -0
  74. package/src/progress/index.ts +23 -0
  75. package/src/progress/indicator/ProgressIndicator.vue +74 -0
  76. package/src/progress/label/ProgressLabel.vue +63 -0
  77. package/src/progress/root/ProgressRoot.vue +160 -0
  78. package/src/progress/root/ProgressRootContext.ts +51 -0
  79. package/src/progress/root/ProgressRootDataAttributes.ts +14 -0
  80. package/src/progress/root/stateAttributesMapping.ts +18 -0
  81. package/src/progress/track/ProgressTrack.vue +48 -0
  82. package/src/progress/value/ProgressValue.vue +92 -0
  83. package/src/scroll-area/constants.ts +2 -0
  84. package/src/scroll-area/content/ScrollAreaContent.vue +87 -0
  85. package/src/scroll-area/corner/ScrollAreaCorner.vue +64 -0
  86. package/src/scroll-area/index.ts +25 -0
  87. package/src/scroll-area/root/ScrollAreaRoot.vue +297 -0
  88. package/src/scroll-area/root/ScrollAreaRootContext.ts +89 -0
  89. package/src/scroll-area/root/ScrollAreaRootCssVars.ts +4 -0
  90. package/src/scroll-area/root/ScrollAreaRootDataAttributes.ts +9 -0
  91. package/src/scroll-area/root/stateAttributes.ts +14 -0
  92. package/src/scroll-area/scrollbar/ScrollAreaScrollbar.vue +263 -0
  93. package/src/scroll-area/scrollbar/ScrollAreaScrollbarContext.ts +20 -0
  94. package/src/scroll-area/scrollbar/ScrollAreaScrollbarCssVars.ts +4 -0
  95. package/src/scroll-area/scrollbar/ScrollAreaScrollbarDataAttributes.ts +11 -0
  96. package/src/scroll-area/thumb/ScrollAreaThumb.vue +120 -0
  97. package/src/scroll-area/thumb/ScrollAreaThumbDataAttributes.ts +3 -0
  98. package/src/scroll-area/utils/getOffset.ts +34 -0
  99. package/src/scroll-area/viewport/ScrollAreaViewport.vue +379 -0
  100. package/src/scroll-area/viewport/ScrollAreaViewportContext.ts +20 -0
  101. package/src/scroll-area/viewport/ScrollAreaViewportCssVars.ts +6 -0
  102. package/src/scroll-area/viewport/ScrollAreaViewportDataAttributes.ts +9 -0
  103. package/src/utils/detectBrowser.ts +15 -0
  104. package/src/utils/formatNumber.ts +60 -2
  105. package/src/utils/scrollEdges.ts +33 -0
  106. package/src/utils/styles.ts +28 -0
  107. package/src/utils/useInterval.ts +45 -0
  108. package/src/utils/usePressAndHold.ts +260 -0
  109. package/src/utils/useValueChanged.ts +21 -0
@@ -0,0 +1,626 @@
1
+ <script setup lang="ts">
2
+ import type { FieldRootState } from '../../field/root/FieldRoot.vue'
3
+ import type { BaseUIComponentProps } from '../../utils/types'
4
+ import type {
5
+ Direction,
6
+ EventWithOptionalKeyState,
7
+ IncrementValueParameters,
8
+ } from '../utils/types'
9
+ import type {
10
+ InputMode,
11
+ NumberFieldRootChangeEventDetails,
12
+ NumberFieldRootChangeEventReason,
13
+ NumberFieldRootCommitEventDetails,
14
+ } from './NumberFieldRootContext'
15
+ import { computed, provide, ref, useAttrs, watch, watchEffect } from 'vue'
16
+ import { useFieldRootContext } from '../../field/root/FieldRootContext'
17
+ import { useField } from '../../field/useField'
18
+ import { activeElement } from '../../floating-ui-vue/utils'
19
+ import { useFormContext } from '../../form/FormContext'
20
+ import { useLabelableId } from '../../labelable-provider/useLabelableId'
21
+ import { createChangeEventDetails } from '../../utils/createBaseUIEventDetails'
22
+ import { isIOS } from '../../utils/detectBrowser'
23
+ import { formatNumber, formatNumberMaxPrecision } from '../../utils/formatNumber'
24
+ import { ownerDocument } from '../../utils/owner'
25
+ import { REASONS } from '../../utils/reasons'
26
+ import { useControllableState } from '../../utils/useControllableState'
27
+ import { useRenderElement } from '../../utils/useRenderElement'
28
+ import { visuallyHidden, visuallyHiddenInput } from '../../utils/visuallyHidden'
29
+ import { DEFAULT_STEP } from '../utils/constants'
30
+ import {
31
+ BASE_NON_NUMERIC_SYMBOLS,
32
+ getNumberLocaleDetails,
33
+ MINUS_SIGNS_WITH_ASCII,
34
+ PERCENTAGES,
35
+ PERMILLE,
36
+ PLUS_SIGNS_WITH_ASCII,
37
+ SPACE_SEPARATOR_RE,
38
+ } from '../utils/parse'
39
+ import { stateAttributesMapping } from '../utils/stateAttributesMapping'
40
+ import { hasNumberFormatRoundingOptions, toValidatedNumber } from '../utils/validate'
41
+ import { numberFieldRootContextKey } from './NumberFieldRootContext'
42
+
43
+ export interface NumberFieldRootState extends FieldRootState {
44
+ /**
45
+ * The raw numeric value of the field.
46
+ */
47
+ value: number | null
48
+ /**
49
+ * The formatted string value presented in the input element.
50
+ */
51
+ inputValue: string
52
+ /**
53
+ * Whether the user must enter a value before submitting a form.
54
+ */
55
+ required: boolean
56
+ /**
57
+ * Whether the component should ignore user interaction.
58
+ */
59
+ disabled: boolean
60
+ /**
61
+ * Whether the user should be unable to change the field value.
62
+ */
63
+ readOnly: boolean
64
+ /**
65
+ * Whether the user is currently scrubbing the field.
66
+ */
67
+ scrubbing: boolean
68
+ }
69
+
70
+ export interface NumberFieldRootProps extends BaseUIComponentProps<NumberFieldRootState> {
71
+ /**
72
+ * The id of the input element.
73
+ */
74
+ id?: string
75
+ /**
76
+ * The minimum value of the input element.
77
+ */
78
+ min?: number
79
+ /**
80
+ * The maximum value of the input element.
81
+ */
82
+ max?: number
83
+ /**
84
+ * When true, direct text entry may be outside the `min`/`max` range without clamping,
85
+ * so native range underflow/overflow validation can occur.
86
+ * Step-based interactions (keyboard arrows, buttons, wheel, scrub) still clamp.
87
+ * @default false
88
+ */
89
+ allowOutOfRange?: boolean
90
+ /**
91
+ * The small step value of the input element when incrementing while the alt key is held. Snaps
92
+ * to multiples of this value.
93
+ * @default 0.1
94
+ */
95
+ smallStep?: number
96
+ /**
97
+ * Amount to increment and decrement with the buttons and arrow keys, or to scrub with pointer
98
+ * movement in the scrub area.
99
+ * Specify `step="any"` to always disable step validation.
100
+ * @default 1
101
+ */
102
+ step?: number | 'any'
103
+ /**
104
+ * The large step value of the input element when incrementing while the shift key is held. Snaps
105
+ * to multiples of this value.
106
+ * @default 10
107
+ */
108
+ largeStep?: number
109
+ /**
110
+ * Whether the user must enter a value before submitting a form.
111
+ * @default false
112
+ */
113
+ required?: boolean
114
+ /**
115
+ * Whether the component should ignore user interaction.
116
+ * @default false
117
+ */
118
+ disabled?: boolean
119
+ /**
120
+ * Whether the user should be unable to change the field value.
121
+ * @default false
122
+ */
123
+ readOnly?: boolean
124
+ /**
125
+ * Identifies the field when a form is submitted.
126
+ */
127
+ name?: string
128
+ /**
129
+ * Identifies the form that owns the hidden input.
130
+ * Useful when the number field is rendered outside the form.
131
+ */
132
+ form?: string
133
+ /**
134
+ * The raw numeric value of the field.
135
+ */
136
+ value?: number | null
137
+ /**
138
+ * The uncontrolled value of the field when it's initially rendered.
139
+ *
140
+ * To render a controlled number field, use the `value` prop instead.
141
+ */
142
+ defaultValue?: number
143
+ /**
144
+ * Whether to allow the user to scrub the input value with the mouse wheel while focused and
145
+ * hovering over the input.
146
+ * @default false
147
+ */
148
+ allowWheelScrub?: boolean
149
+ /**
150
+ * Whether the value should snap to the nearest step when incrementing or decrementing.
151
+ * @default false
152
+ */
153
+ snapOnStep?: boolean
154
+ /**
155
+ * Options to format the input value.
156
+ */
157
+ format?: Intl.NumberFormatOptions
158
+ /**
159
+ * The locale of the input element.
160
+ * Defaults to the user's runtime locale.
161
+ */
162
+ locale?: Intl.LocalesArgument
163
+ }
164
+
165
+ defineOptions({
166
+ name: 'NumberFieldRoot',
167
+ inheritAttrs: false,
168
+ })
169
+
170
+ const props = withDefaults(defineProps<NumberFieldRootProps>(), {
171
+ as: 'div',
172
+ smallStep: 0.1,
173
+ step: 1,
174
+ largeStep: 10,
175
+ required: false,
176
+ disabled: false,
177
+ readOnly: false,
178
+ allowWheelScrub: false,
179
+ snapOnStep: false,
180
+ allowOutOfRange: false,
181
+ })
182
+
183
+ const emit = defineEmits<{
184
+ /**
185
+ * Fired when the number value changes.
186
+ */
187
+ valueChange: [value: number | null, eventDetails: NumberFieldRootChangeEventDetails]
188
+ /**
189
+ * Fired when the value is committed (input blur, pointer release, or keyboard interaction).
190
+ */
191
+ valueCommitted: [value: number | null, eventDetails: NumberFieldRootCommitEventDetails]
192
+ }>()
193
+
194
+ const attrs = useAttrs()
195
+ const attrsObject = attrs as Record<string, any>
196
+
197
+ const {
198
+ setDirty,
199
+ validityData,
200
+ disabled: fieldDisabled,
201
+ setFilled,
202
+ invalid,
203
+ name: fieldName,
204
+ state: fieldState,
205
+ validation,
206
+ } = useFieldRootContext()
207
+ const { clearErrors } = useFormContext()
208
+
209
+ const disabled = computed(() => fieldDisabled.value || props.disabled)
210
+ const readOnly = computed(() => props.readOnly)
211
+ const required = computed(() => props.required)
212
+ const nameProp = computed(() => props.name)
213
+ const name = computed(() => fieldName.value ?? props.name)
214
+ const step = computed(() => (props.step === 'any' ? 1 : props.step))
215
+ const smallStep = computed(() => props.smallStep)
216
+ const largeStep = computed(() => props.largeStep)
217
+ const locale = computed(() => props.locale)
218
+ const format = computed(() => props.format)
219
+ const min = computed(() => props.min)
220
+ const max = computed(() => props.max)
221
+
222
+ const minWithDefault = computed(() => props.min ?? Number.MIN_SAFE_INTEGER)
223
+ const maxWithDefault = computed(() => props.max ?? Number.MAX_SAFE_INTEGER)
224
+ const minWithZeroDefault = computed(() => props.min ?? 0)
225
+ const formatStyle = computed(() => props.format?.style)
226
+
227
+ const isScrubbingRef = ref(false)
228
+ function setIsScrubbing(value: boolean) {
229
+ isScrubbingRef.value = value
230
+ }
231
+
232
+ const inputRef = ref<HTMLInputElement | null>(null)
233
+ const validationInputRef = ref<HTMLInputElement | null>(null)
234
+
235
+ const id = useLabelableId({ id: computed(() => props.id) })
236
+
237
+ const { value: valueUnwrapped, setValue: setValueUnwrapped } = useControllableState<number | null>({
238
+ controlled: () => props.value,
239
+ default: () => props.defaultValue ?? null,
240
+ name: 'NumberField',
241
+ state: 'value',
242
+ })
243
+
244
+ const value = computed(() => valueUnwrapped.value ?? null)
245
+
246
+ const valueRef = ref<number | null>(value.value)
247
+ watch(value, (next) => {
248
+ valueRef.value = next
249
+ }, { flush: 'post' })
250
+
251
+ const formatOptionsRef = format
252
+
253
+ const hasPendingCommitRef = ref(false)
254
+ const allowInputSyncRef = ref(true)
255
+ const lastChangedValueRef = ref<number | null>(null)
256
+
257
+ function onValueCommitted(
258
+ nextValue: number | null,
259
+ eventDetails: NumberFieldRootCommitEventDetails,
260
+ ) {
261
+ hasPendingCommitRef.value = false
262
+ emit('valueCommitted', nextValue, eventDetails)
263
+ }
264
+
265
+ function getControlledInputValue(nextValue: number | null) {
266
+ return hasNumberFormatRoundingOptions(format.value)
267
+ ? formatNumber(nextValue, locale.value, format.value)
268
+ : formatNumberMaxPrecision(nextValue, locale.value, format.value)
269
+ }
270
+
271
+ const inputValue = ref<string>(
272
+ props.value !== undefined
273
+ ? getControlledInputValue(value.value)
274
+ : formatNumber(value.value, locale.value, format.value),
275
+ )
276
+ function setInputValue(next: string) {
277
+ inputValue.value = next
278
+ }
279
+
280
+ const inputMode = ref<InputMode>('numeric')
281
+
282
+ watchEffect(() => {
283
+ setFilled(value.value !== null)
284
+ })
285
+
286
+ watchEffect(() => {
287
+ validation.setInputRef(validationInputRef.value)
288
+ })
289
+
290
+ useField({
291
+ enabled: computed(() => !disabled.value),
292
+ id,
293
+ name,
294
+ commit: (v: unknown) => validation.commit(v),
295
+ value,
296
+ getValue: () => value.value,
297
+ controlRef: inputRef,
298
+ })
299
+
300
+ function getAllowedNonNumericKeys() {
301
+ const { decimal, group, currency, literal } = getNumberLocaleDetails(locale.value, format.value)
302
+
303
+ const keys = new Set<string | undefined>()
304
+ BASE_NON_NUMERIC_SYMBOLS.forEach(symbol => keys.add(symbol))
305
+ if (decimal) {
306
+ keys.add(decimal)
307
+ }
308
+ if (group) {
309
+ keys.add(group)
310
+ if (SPACE_SEPARATOR_RE.test(group)) {
311
+ keys.add(' ')
312
+ }
313
+ }
314
+
315
+ const allowPercentSymbols
316
+ = formatStyle.value === 'percent'
317
+ || (formatStyle.value === 'unit' && format.value?.unit === 'percent')
318
+ const allowPermilleSymbols
319
+ = formatStyle.value === 'percent'
320
+ || (formatStyle.value === 'unit' && format.value?.unit === 'permille')
321
+
322
+ if (allowPercentSymbols) {
323
+ PERCENTAGES.forEach(key => keys.add(key))
324
+ }
325
+ if (allowPermilleSymbols) {
326
+ PERMILLE.forEach(key => keys.add(key))
327
+ }
328
+
329
+ if (formatStyle.value === 'currency' && currency) {
330
+ keys.add(currency)
331
+ }
332
+
333
+ if (literal) {
334
+ // Some locales (e.g. de-DE) insert a literal space character between the number
335
+ // and the symbol, so allow those characters to be typed/removed.
336
+ Array.from(literal).forEach(char => keys.add(char))
337
+ if (SPACE_SEPARATOR_RE.test(literal)) {
338
+ keys.add(' ')
339
+ }
340
+ }
341
+
342
+ // Allow plus sign in all cases; minus sign only when negatives are valid
343
+ PLUS_SIGNS_WITH_ASCII.forEach(key => keys.add(key))
344
+ if (minWithDefault.value < 0) {
345
+ MINUS_SIGNS_WITH_ASCII.forEach(key => keys.add(key))
346
+ }
347
+
348
+ return keys
349
+ }
350
+
351
+ function getStepAmount(event?: EventWithOptionalKeyState) {
352
+ if (event?.altKey) {
353
+ return smallStep.value
354
+ }
355
+ if (event?.shiftKey) {
356
+ return largeStep.value
357
+ }
358
+ return step.value
359
+ }
360
+
361
+ function setValue(unvalidatedValue: number | null, details: NumberFieldRootChangeEventDetails): boolean {
362
+ const eventWithOptionalKeyState = details.event as EventWithOptionalKeyState
363
+ const dir = details.direction
364
+ const reason = details.reason
365
+ // Only allow out-of-range values for direct text entry (native-like behavior).
366
+ // Step-based interactions (keyboard arrows, buttons, wheel, scrub) still clamp to min/max.
367
+ const shouldClampValue
368
+ = !props.allowOutOfRange
369
+ || !(
370
+ reason === REASONS.inputChange
371
+ || reason === REASONS.inputBlur
372
+ || reason === REASONS.inputPaste
373
+ || reason === REASONS.inputClear
374
+ || reason === REASONS.none
375
+ )
376
+
377
+ const validatedValue = toValidatedNumber(unvalidatedValue, {
378
+ step: dir ? getStepAmount(eventWithOptionalKeyState) * dir : undefined,
379
+ format: formatOptionsRef.value,
380
+ minWithDefault: minWithDefault.value,
381
+ maxWithDefault: maxWithDefault.value,
382
+ minWithZeroDefault: minWithZeroDefault.value,
383
+ snapOnStep: props.snapOnStep,
384
+ small: eventWithOptionalKeyState?.altKey ?? false,
385
+ clamp: shouldClampValue,
386
+ })
387
+
388
+ // Determine whether we should notify about a change even if the numeric value is unchanged.
389
+ const isInputReason
390
+ = details.reason === REASONS.inputChange
391
+ || details.reason === REASONS.inputClear
392
+ || details.reason === REASONS.inputBlur
393
+ || details.reason === REASONS.inputPaste
394
+ || details.reason === REASONS.none
395
+ const shouldFireChange
396
+ = validatedValue !== value.value
397
+ || (isInputReason && (unvalidatedValue !== value.value || allowInputSyncRef.value === false))
398
+
399
+ if (shouldFireChange) {
400
+ emit('valueChange', validatedValue, details)
401
+
402
+ if (details.isCanceled) {
403
+ return shouldFireChange
404
+ }
405
+
406
+ setValueUnwrapped(validatedValue)
407
+ setDirty(validatedValue !== validityData.value.initialValue)
408
+ hasPendingCommitRef.value = true
409
+ }
410
+
411
+ lastChangedValueRef.value = validatedValue
412
+
413
+ // Keep the visible input in sync immediately when programmatic changes occur.
414
+ // In controlled mode, the prop remains the source of truth if the parent rejects the change.
415
+ if (allowInputSyncRef.value) {
416
+ const nextInputValue = props.value !== undefined
417
+ ? getControlledInputValue(value.value)
418
+ : formatNumber(validatedValue, locale.value, format.value)
419
+
420
+ setInputValue(nextInputValue)
421
+ }
422
+
423
+ return shouldFireChange
424
+ }
425
+
426
+ function incrementValue(
427
+ amount: number,
428
+ { direction, currentValue, event, reason }: IncrementValueParameters,
429
+ ): boolean {
430
+ const prevValue = currentValue == null ? valueRef.value : currentValue
431
+ const nextValue
432
+ = typeof prevValue === 'number' ? prevValue + amount * direction : Math.max(0, props.min ?? 0)
433
+ return setValue(
434
+ nextValue,
435
+ createChangeEventDetails<NumberFieldRootChangeEventReason, { direction?: Direction }>(
436
+ reason,
437
+ event as any,
438
+ undefined,
439
+ { direction },
440
+ ),
441
+ )
442
+ }
443
+
444
+ // Sync the formatted input value when the parsed value or formatting changes.
445
+ watch(
446
+ [value, locale, format],
447
+ () => {
448
+ if (!allowInputSyncRef.value) {
449
+ return
450
+ }
451
+
452
+ const nextInputValue
453
+ = props.value !== undefined
454
+ ? getControlledInputValue(value.value)
455
+ : formatNumber(value.value, locale.value, format.value)
456
+
457
+ if (nextInputValue !== inputValue.value) {
458
+ setInputValue(nextInputValue)
459
+ }
460
+ },
461
+ { flush: 'post' },
462
+ )
463
+
464
+ // iOS numeric software keyboard doesn't have a minus key, so we need to use the default
465
+ // keyboard to let the user input a negative number.
466
+ watchEffect(() => {
467
+ if (!isIOS) {
468
+ return
469
+ }
470
+
471
+ let computedInputMode: InputMode = 'text'
472
+
473
+ if (minWithDefault.value >= 0) {
474
+ // iOS numeric software keyboard doesn't have a decimal key for "numeric" input mode, but
475
+ // this is better than the "text" input if possible to use.
476
+ computedInputMode = 'decimal'
477
+ }
478
+
479
+ inputMode.value = computedInputMode
480
+ })
481
+
482
+ // The `onWheel` prop can't be prevented, so we need to use a global event listener.
483
+ watchEffect((onCleanup) => {
484
+ const element = inputRef.value
485
+ if (disabled.value || readOnly.value || !props.allowWheelScrub || !element) {
486
+ return
487
+ }
488
+
489
+ function handleWheel(event: WheelEvent) {
490
+ if (
491
+ // Allow pinch-zooming.
492
+ event.ctrlKey
493
+ || activeElement(ownerDocument(inputRef.value)!) !== inputRef.value
494
+ ) {
495
+ return
496
+ }
497
+
498
+ // Prevent the default behavior to avoid scrolling the page.
499
+ event.preventDefault()
500
+ allowInputSyncRef.value = true
501
+
502
+ const amount = getStepAmount(event) ?? DEFAULT_STEP
503
+
504
+ incrementValue(amount, {
505
+ direction: event.deltaY > 0 ? -1 : 1,
506
+ event,
507
+ reason: 'wheel',
508
+ })
509
+ }
510
+
511
+ element.addEventListener('wheel', handleWheel, { passive: false })
512
+ onCleanup(() => {
513
+ element.removeEventListener('wheel', handleWheel)
514
+ })
515
+ })
516
+
517
+ const state = computed<NumberFieldRootState>(() => ({
518
+ ...fieldState.value,
519
+ disabled: disabled.value,
520
+ readOnly: readOnly.value,
521
+ required: required.value,
522
+ value: value.value,
523
+ inputValue: inputValue.value,
524
+ scrubbing: isScrubbingRef.value,
525
+ }))
526
+
527
+ provide(numberFieldRootContextKey, {
528
+ inputRef,
529
+ inputValue,
530
+ value,
531
+ minWithDefault,
532
+ maxWithDefault,
533
+ disabled,
534
+ readOnly,
535
+ id,
536
+ setValue,
537
+ incrementValue,
538
+ getStepAmount,
539
+ allowInputSyncRef,
540
+ formatOptionsRef,
541
+ valueRef,
542
+ lastChangedValueRef,
543
+ hasPendingCommitRef,
544
+ name,
545
+ nameProp,
546
+ required,
547
+ invalid,
548
+ inputMode,
549
+ getAllowedNonNumericKeys,
550
+ min,
551
+ max,
552
+ setInputValue,
553
+ locale,
554
+ isScrubbing: isScrubbingRef,
555
+ setIsScrubbing,
556
+ state,
557
+ onValueCommitted,
558
+ })
559
+
560
+ const rootRef = ref<HTMLElement | null>(null)
561
+
562
+ const {
563
+ tag: rootTag,
564
+ mergedProps: rootMergedProps,
565
+ renderless: rootRenderless,
566
+ ref: rootRenderRef,
567
+ } = useRenderElement({
568
+ componentProps: props,
569
+ state,
570
+ props: attrsObject,
571
+ stateAttributesMapping,
572
+ defaultTagName: 'div',
573
+ ref: rootRef,
574
+ })
575
+
576
+ const hiddenInputProps = computed<Record<string, any>>(() => ({
577
+ ...validation.getValidationProps(),
578
+ 'type': 'number',
579
+ 'form': props.form,
580
+ 'name': name.value,
581
+ 'value': value.value ?? '',
582
+ 'min': props.min,
583
+ 'max': props.max,
584
+ 'step': props.step,
585
+ 'disabled': disabled.value,
586
+ 'required': required.value,
587
+ 'aria-hidden': true,
588
+ 'tabindex': -1,
589
+ 'style': name.value ? visuallyHiddenInput : visuallyHidden,
590
+ }))
591
+
592
+ function handleHiddenFocus() {
593
+ inputRef.value?.focus()
594
+ }
595
+
596
+ function handleHiddenInput(event: Event) {
597
+ const target = event.currentTarget as HTMLInputElement
598
+ if (event.defaultPrevented || disabled.value || readOnly.value) {
599
+ return
600
+ }
601
+
602
+ // Handle browser autofill.
603
+ const nextValue = target.valueAsNumber
604
+ const parsedValue = Number.isNaN(nextValue) ? null : nextValue
605
+ const details = createChangeEventDetails(REASONS.none, event)
606
+
607
+ setDirty(parsedValue !== validityData.value.initialValue)
608
+ setValue(parsedValue, details)
609
+ clearErrors(name.value)
610
+ void validation.commit(parsedValue, true)
611
+ }
612
+ </script>
613
+
614
+ <template>
615
+ <component :is="rootTag" v-if="!rootRenderless" :ref="rootRenderRef" v-bind="rootMergedProps">
616
+ <slot :state="state" />
617
+ </component>
618
+ <slot v-else :ref="rootRenderRef" :props="rootMergedProps" :state="state" />
619
+ <input
620
+ ref="validationInputRef"
621
+ v-bind="hiddenInputProps"
622
+ suppresshydrationwarning
623
+ @input="handleHiddenInput"
624
+ @focus="handleHiddenFocus"
625
+ >
626
+ </template>
@@ -0,0 +1,94 @@
1
+ import type { ComputedRef, InjectionKey, Ref } from 'vue'
2
+ import type {
3
+ BaseUIChangeEventDetails,
4
+ BaseUIGenericEventDetails,
5
+ } from '../../utils/createBaseUIEventDetails'
6
+ import type { REASONS } from '../../utils/reasons'
7
+ import type {
8
+ ChangeEventCustomProperties,
9
+ EventWithOptionalKeyState,
10
+ IncrementValueParameters,
11
+ } from '../utils/types'
12
+ import type { NumberFieldRootState } from './NumberFieldRoot.vue'
13
+ import { inject } from 'vue'
14
+
15
+ export type InputMode = 'numeric' | 'decimal' | 'text'
16
+
17
+ export type NumberFieldRootChangeEventReason
18
+ = | typeof REASONS.inputChange
19
+ | typeof REASONS.inputClear
20
+ | typeof REASONS.inputBlur
21
+ | typeof REASONS.inputPaste
22
+ | typeof REASONS.keyboard
23
+ | typeof REASONS.incrementPress
24
+ | typeof REASONS.decrementPress
25
+ | typeof REASONS.wheel
26
+ | typeof REASONS.scrub
27
+ | typeof REASONS.none
28
+ export type NumberFieldRootChangeEventDetails = BaseUIChangeEventDetails<
29
+ NumberFieldRootChangeEventReason,
30
+ ChangeEventCustomProperties
31
+ >
32
+
33
+ export type NumberFieldRootCommitEventReason
34
+ = | typeof REASONS.inputBlur
35
+ | typeof REASONS.inputClear
36
+ | typeof REASONS.keyboard
37
+ | typeof REASONS.incrementPress
38
+ | typeof REASONS.decrementPress
39
+ | typeof REASONS.wheel
40
+ | typeof REASONS.scrub
41
+ | typeof REASONS.none
42
+ export type NumberFieldRootCommitEventDetails
43
+ = BaseUIGenericEventDetails<NumberFieldRootCommitEventReason>
44
+
45
+ export interface NumberFieldRootContext {
46
+ inputValue: Readonly<Ref<string>>
47
+ value: Readonly<Ref<number | null>>
48
+ minWithDefault: ComputedRef<number>
49
+ maxWithDefault: ComputedRef<number>
50
+ disabled: ComputedRef<boolean>
51
+ readOnly: ComputedRef<boolean>
52
+ id: ComputedRef<string | undefined>
53
+ setValue: (value: number | null, details: NumberFieldRootChangeEventDetails) => boolean
54
+ getStepAmount: (event?: EventWithOptionalKeyState) => number | undefined
55
+ incrementValue: (amount: number, params: IncrementValueParameters) => boolean
56
+ inputRef: Ref<HTMLInputElement | null>
57
+ allowInputSyncRef: Ref<boolean>
58
+ formatOptionsRef: ComputedRef<Intl.NumberFormatOptions | undefined>
59
+ valueRef: Ref<number | null>
60
+ lastChangedValueRef: Ref<number | null>
61
+ hasPendingCommitRef: Ref<boolean>
62
+ name: ComputedRef<string | undefined>
63
+ nameProp: ComputedRef<string | undefined>
64
+ required: ComputedRef<boolean>
65
+ invalid: Readonly<Ref<boolean | undefined>>
66
+ inputMode: Readonly<Ref<InputMode>>
67
+ getAllowedNonNumericKeys: () => Set<string | undefined>
68
+ min: ComputedRef<number | undefined>
69
+ max: ComputedRef<number | undefined>
70
+ setInputValue: (value: string) => void
71
+ locale: ComputedRef<Intl.LocalesArgument>
72
+ isScrubbing: Readonly<Ref<boolean>>
73
+ setIsScrubbing: (value: boolean) => void
74
+ state: ComputedRef<NumberFieldRootState>
75
+ onValueCommitted: (
76
+ value: number | null,
77
+ eventDetails: NumberFieldRootCommitEventDetails,
78
+ ) => void
79
+ }
80
+
81
+ export const numberFieldRootContextKey: InjectionKey<NumberFieldRootContext>
82
+ = Symbol('NumberFieldRootContext')
83
+
84
+ export function useNumberFieldRootContext() {
85
+ const context = inject(numberFieldRootContextKey, undefined)
86
+
87
+ if (context === undefined) {
88
+ throw new Error(
89
+ 'Base UI Vue: NumberFieldRootContext is missing. NumberField parts must be placed within <NumberFieldRoot>.',
90
+ )
91
+ }
92
+
93
+ return context
94
+ }