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.
- package/dist/button/ToolbarButton.cjs +6 -0
- package/dist/button/ToolbarButton.js +1 -1
- package/dist/content/ScrollAreaContent.cjs +168 -0
- package/dist/content/ScrollAreaContent.cjs.map +1 -0
- package/dist/content/ScrollAreaContent.js +133 -0
- package/dist/content/ScrollAreaContent.js.map +1 -0
- package/dist/control/SliderControl.js +2 -2
- package/dist/corner/ScrollAreaCorner.cjs +77 -0
- package/dist/corner/ScrollAreaCorner.cjs.map +1 -0
- package/dist/corner/ScrollAreaCorner.js +72 -0
- package/dist/corner/ScrollAreaCorner.js.map +1 -0
- package/dist/decrement/NumberFieldDecrement.cjs +861 -0
- package/dist/decrement/NumberFieldDecrement.cjs.map +1 -0
- package/dist/decrement/NumberFieldDecrement.js +700 -0
- package/dist/decrement/NumberFieldDecrement.js.map +1 -0
- package/dist/fallback/AvatarFallback.cjs +2 -46
- package/dist/fallback/AvatarFallback.cjs.map +1 -1
- package/dist/fallback/AvatarFallback.js +3 -41
- package/dist/fallback/AvatarFallback.js.map +1 -1
- package/dist/group/NumberFieldGroup.cjs +72 -0
- package/dist/group/NumberFieldGroup.cjs.map +1 -0
- package/dist/group/NumberFieldGroup.js +67 -0
- package/dist/group/NumberFieldGroup.js.map +1 -0
- package/dist/increment/NumberFieldIncrement.cjs +112 -0
- package/dist/increment/NumberFieldIncrement.cjs.map +1 -0
- package/dist/increment/NumberFieldIncrement.js +107 -0
- package/dist/increment/NumberFieldIncrement.js.map +1 -0
- package/dist/index.cjs +52 -0
- package/dist/index.d.cts +1761 -430
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +1761 -430
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -2
- package/dist/index2.cjs +4065 -60
- package/dist/index2.cjs.map +1 -1
- package/dist/index2.js +3955 -184
- package/dist/index2.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +6 -0
- package/src/input/Input.vue +37 -0
- package/src/input/InputDataAttributes.ts +30 -0
- package/src/input/index.ts +4 -0
- package/src/meter/index.ts +16 -0
- package/src/meter/indicator/MeterIndicator.vue +65 -0
- package/src/meter/label/MeterLabel.vue +63 -0
- package/src/meter/root/MeterRoot.vue +131 -0
- package/src/meter/root/MeterRootContext.ts +41 -0
- package/src/meter/track/MeterTrack.vue +46 -0
- package/src/meter/value/MeterValue.vue +85 -0
- package/src/number-field/decrement/NumberFieldDecrement.vue +109 -0
- package/src/number-field/group/NumberFieldGroup.vue +47 -0
- package/src/number-field/increment/NumberFieldIncrement.vue +109 -0
- package/src/number-field/index.ts +42 -0
- package/src/number-field/input/NumberFieldInput.vue +455 -0
- package/src/number-field/root/NumberFieldRoot.vue +626 -0
- package/src/number-field/root/NumberFieldRootContext.ts +94 -0
- package/src/number-field/root/useNumberFieldButton.ts +171 -0
- package/src/number-field/scrub-area/NumberFieldScrubArea.vue +359 -0
- package/src/number-field/scrub-area/NumberFieldScrubAreaContext.ts +26 -0
- package/src/number-field/scrub-area-cursor/NumberFieldScrubAreaCursor.vue +75 -0
- package/src/number-field/utils/constants.ts +4 -0
- package/src/number-field/utils/getViewportRect.ts +34 -0
- package/src/number-field/utils/parse.ts +248 -0
- package/src/number-field/utils/stateAttributesMapping.ts +9 -0
- package/src/number-field/utils/subscribeToVisualViewportResize.ts +27 -0
- package/src/number-field/utils/types.ts +24 -0
- package/src/number-field/utils/validate.ts +120 -0
- package/src/otp-field/index.ts +22 -0
- package/src/otp-field/input/OtpFieldInput.vue +336 -0
- package/src/otp-field/root/OtpFieldRoot.vue +583 -0
- package/src/otp-field/root/OtpFieldRootContext.ts +81 -0
- package/src/otp-field/utils/otp.ts +135 -0
- package/src/otp-field/utils/stateAttributesMapping.ts +16 -0
- package/src/progress/index.ts +23 -0
- package/src/progress/indicator/ProgressIndicator.vue +74 -0
- package/src/progress/label/ProgressLabel.vue +63 -0
- package/src/progress/root/ProgressRoot.vue +160 -0
- package/src/progress/root/ProgressRootContext.ts +51 -0
- package/src/progress/root/ProgressRootDataAttributes.ts +14 -0
- package/src/progress/root/stateAttributesMapping.ts +18 -0
- package/src/progress/track/ProgressTrack.vue +48 -0
- package/src/progress/value/ProgressValue.vue +92 -0
- package/src/scroll-area/constants.ts +2 -0
- package/src/scroll-area/content/ScrollAreaContent.vue +87 -0
- package/src/scroll-area/corner/ScrollAreaCorner.vue +64 -0
- package/src/scroll-area/index.ts +25 -0
- package/src/scroll-area/root/ScrollAreaRoot.vue +297 -0
- package/src/scroll-area/root/ScrollAreaRootContext.ts +89 -0
- package/src/scroll-area/root/ScrollAreaRootCssVars.ts +4 -0
- package/src/scroll-area/root/ScrollAreaRootDataAttributes.ts +9 -0
- package/src/scroll-area/root/stateAttributes.ts +14 -0
- package/src/scroll-area/scrollbar/ScrollAreaScrollbar.vue +263 -0
- package/src/scroll-area/scrollbar/ScrollAreaScrollbarContext.ts +20 -0
- package/src/scroll-area/scrollbar/ScrollAreaScrollbarCssVars.ts +4 -0
- package/src/scroll-area/scrollbar/ScrollAreaScrollbarDataAttributes.ts +11 -0
- package/src/scroll-area/thumb/ScrollAreaThumb.vue +120 -0
- package/src/scroll-area/thumb/ScrollAreaThumbDataAttributes.ts +3 -0
- package/src/scroll-area/utils/getOffset.ts +34 -0
- package/src/scroll-area/viewport/ScrollAreaViewport.vue +379 -0
- package/src/scroll-area/viewport/ScrollAreaViewportContext.ts +20 -0
- package/src/scroll-area/viewport/ScrollAreaViewportCssVars.ts +6 -0
- package/src/scroll-area/viewport/ScrollAreaViewportDataAttributes.ts +9 -0
- package/src/utils/detectBrowser.ts +15 -0
- package/src/utils/formatNumber.ts +60 -2
- package/src/utils/scrollEdges.ts +33 -0
- package/src/utils/styles.ts +28 -0
- package/src/utils/useInterval.ts +45 -0
- package/src/utils/usePressAndHold.ts +260 -0
- 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
|
+
}
|