-
-
Notifications
You must be signed in to change notification settings - Fork 296
/
Copy pathNumberFieldRoot.vue
217 lines (189 loc) · 7 KB
/
NumberFieldRoot.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
<script lang="ts">
import type { PrimitiveProps } from '@/Primitive'
import { useVModel } from '@vueuse/core'
import { clamp, createContext, snapValueToStep, useFormControl, useLocale } from '@/shared'
import { type HTMLAttributes, type Ref, computed, ref, toRefs } from 'vue'
import type { FormFieldProps } from '@/shared/types'
export interface NumberFieldRootProps extends PrimitiveProps, FormFieldProps {
defaultValue?: number
modelValue?: number | null
/** The smallest value allowed for the input. */
min?: number
/** The largest value allowed for the input. */
max?: number
/** The amount that the input value changes with each increment or decrement "tick". */
step?: number
/** Formatting options for the value displayed in the number field. This also affects what characters are allowed to be typed by the user. */
formatOptions?: Intl.NumberFormatOptions
/** The locale to use for formatting dates */
locale?: string
/** When `true`, prevents the user from interacting with the Number Field. */
disabled?: boolean
/** Id of the element */
id?: string
}
export type NumberFieldRootEmits = {
'update:modelValue': [val: number]
}
interface NumberFieldRootContext {
modelValue: Ref<number>
handleIncrease: (multiplier?: number) => void
handleDecrease: (multiplier?: number) => void
handleMinMaxValue: (type: 'min' | 'max') => void
inputEl: Ref<HTMLInputElement | undefined>
onInputElement: (el: HTMLInputElement) => void
inputMode: Ref<HTMLAttributes['inputmode']>
textValue: Ref<string>
validate: (val: string) => boolean
applyInputValue: (val: string) => void
disabled: Ref<boolean>
max: Ref<number | undefined>
min: Ref<number | undefined>
isDecreaseDisabled: Ref<boolean>
isIncreaseDisabled: Ref<boolean>
id: Ref<string | undefined>
}
export const [injectNumberFieldRootContext, provideNumberFieldRootContext] = createContext<NumberFieldRootContext>('NumberFieldRoot')
</script>
<script setup lang="ts">
import { Primitive, usePrimitiveElement } from '@/Primitive'
import { handleDecimalOperation, useNumberFormatter, useNumberParser } from './utils'
import { VisuallyHiddenInput } from '@/VisuallyHidden'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<NumberFieldRootProps>(), {
as: 'div',
defaultValue: undefined,
step: 1,
})
const emits = defineEmits<NumberFieldRootEmits>()
const { disabled, min, max, step, formatOptions, id, locale: propLocale } = toRefs(props)
const modelValue = useVModel(props, 'modelValue', emits, {
defaultValue: props.defaultValue,
passive: (props.modelValue === undefined) as false,
}) as Ref<number>
const { primitiveElement, currentElement } = usePrimitiveElement()
const locale = useLocale(propLocale)
const isFormControl = useFormControl(currentElement)
const inputEl = ref<HTMLInputElement>()
const isDecreaseDisabled = computed(() => (
clampInputValue(modelValue.value) === min.value
|| (min.value && !isNaN(modelValue.value) ? (handleDecimalOperation('-', modelValue.value, step.value) < min.value) : false)),
)
const isIncreaseDisabled = computed(() => (
clampInputValue(modelValue.value) === max.value
|| (max.value && !isNaN(modelValue.value) ? (handleDecimalOperation('+', modelValue.value, step.value) > max.value) : false)),
)
function handleChangingValue(type: 'increase' | 'decrease', multiplier = 1) {
inputEl.value?.focus()
const currentInputValue = numberParser.parse(inputEl.value?.value ?? '')
if (props.disabled)
return
if (isNaN(currentInputValue)) {
modelValue.value = min.value ?? 0
}
else {
if (type === 'increase')
modelValue.value = clampInputValue(currentInputValue + ((step.value ?? 1) * multiplier))
else
modelValue.value = clampInputValue(currentInputValue - ((step.value ?? 1) * multiplier))
}
}
function handleIncrease(multiplier = 1) {
handleChangingValue('increase', multiplier)
}
function handleDecrease(multiplier = 1) {
handleChangingValue('decrease', multiplier)
}
function handleMinMaxValue(type: 'min' | 'max') {
if (type === 'min' && min.value !== undefined)
modelValue.value = clampInputValue(min.value)
else if (type === 'max' && max.value !== undefined)
modelValue.value = clampInputValue(max.value)
}
// Formatter
const numberFormatter = useNumberFormatter(locale, formatOptions)
const numberParser = useNumberParser(locale, formatOptions)
const inputMode = computed<HTMLAttributes['inputmode']>(() => {
// The inputMode attribute influences the software keyboard that is shown on touch devices.
// Browsers and operating systems are quite inconsistent about what keys are available, however.
// We choose between numeric and decimal based on whether we allow negative and fractional numbers,
// and based on testing on various devices to determine what keys are available in each inputMode.
const hasDecimals = numberFormatter.resolvedOptions().maximumFractionDigits! > 0
return hasDecimals ? 'decimal' : 'numeric'
})
// Replace negative textValue formatted using currencySign: 'accounting'
// with a textValue that can be announced using a minus sign.
const textValueFormatter = useNumberFormatter(locale, formatOptions)
const textValue = computed(() => isNaN(modelValue.value) ? '' : textValueFormatter.format(modelValue.value))
function validate(val: string) {
return numberParser.isValidPartialNumber(val, min.value, max.value)
}
function setInputValue(val: string) {
if (inputEl.value)
inputEl.value.value = val
}
function clampInputValue(val: number) {
// Clamp to min and max, round to the nearest step, and round to specified number of digits
let clampedValue: number
if (step.value === undefined || isNaN(step.value))
clampedValue = clamp(val, min.value, max.value)
else
clampedValue = snapValueToStep(val, min.value, max.value, step.value)
clampedValue = numberParser.parse(numberFormatter.format(clampedValue))
return clampedValue
}
function applyInputValue(val: string) {
const parsedValue = numberParser.parse(val)
modelValue.value = clampInputValue(parsedValue)
// Set to empty state if input value is empty
if (!val.length)
return setInputValue(val)
// if it failed to parse, then reset input to formatted version of current number
if (isNaN(parsedValue))
return setInputValue(textValue.value)
return setInputValue(textValue.value)
}
provideNumberFieldRootContext({
modelValue,
handleDecrease,
handleIncrease,
handleMinMaxValue,
inputMode,
inputEl,
onInputElement: el => inputEl.value = el,
textValue,
validate,
applyInputValue,
disabled,
max,
min,
isDecreaseDisabled,
isIncreaseDisabled,
id,
})
</script>
<template>
<Primitive
v-bind="$attrs"
ref="primitiveElement"
role="group"
:as="as"
:as-child="asChild"
:data-disabled="disabled ? '' : undefined"
>
<slot
:model-value="modelValue"
:text-value="textValue"
/>
<VisuallyHiddenInput
v-if="isFormControl && name"
type="text"
:value="modelValue"
:name="name"
:disabled="disabled"
:required="required"
/>
</Primitive>
</template>