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,583 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { FieldRootState } from '../../field/root/FieldRoot.vue'
|
|
3
|
+
import type { BaseUIComponentProps } from '../../utils/types'
|
|
4
|
+
import type { OtpValidationType } from '../utils/otp'
|
|
5
|
+
import type {
|
|
6
|
+
OtpFieldRootChangeEventDetails,
|
|
7
|
+
OtpFieldRootCompleteEventDetails,
|
|
8
|
+
OtpFieldRootInvalidEventDetails,
|
|
9
|
+
} from './OtpFieldRootContext'
|
|
10
|
+
import { computed, provide, ref, useAttrs, watchEffect } from 'vue'
|
|
11
|
+
import CompositeList from '../../composite/list/CompositeList.vue'
|
|
12
|
+
import { useFieldRootContext } from '../../field/root/FieldRootContext'
|
|
13
|
+
import { useField } from '../../field/useField'
|
|
14
|
+
import { contains } from '../../floating-ui-vue/utils'
|
|
15
|
+
import { useFormContext } from '../../form/FormContext'
|
|
16
|
+
import { useLabelableContext } from '../../labelable-provider/LabelableContext'
|
|
17
|
+
import { useAriaLabelledBy } from '../../labelable-provider/useAriaLabelledBy'
|
|
18
|
+
import { useLabelableId } from '../../labelable-provider/useLabelableId'
|
|
19
|
+
import { mergeProps } from '../../merge-props/mergeProps'
|
|
20
|
+
import { createChangeEventDetails, createGenericEventDetails } from '../../utils/createBaseUIEventDetails'
|
|
21
|
+
import { ownerDocument } from '../../utils/owner'
|
|
22
|
+
import { REASONS } from '../../utils/reasons'
|
|
23
|
+
import { useControllableState } from '../../utils/useControllableState'
|
|
24
|
+
import { useRenderElement } from '../../utils/useRenderElement'
|
|
25
|
+
import { useValueChanged } from '../../utils/useValueChanged'
|
|
26
|
+
import { visuallyHidden, visuallyHiddenInput } from '../../utils/visuallyHidden'
|
|
27
|
+
import { warn } from '../../utils/warn'
|
|
28
|
+
import { getOTPValidationConfig, getOTPValueLength, normalizeOTPValue, normalizeOTPValueWithDetails } from '../utils/otp'
|
|
29
|
+
import { rootStateAttributesMapping } from '../utils/stateAttributesMapping'
|
|
30
|
+
import { otpFieldRootContextKey } from './OtpFieldRootContext'
|
|
31
|
+
|
|
32
|
+
export interface OtpFieldRootState extends FieldRootState {
|
|
33
|
+
/**
|
|
34
|
+
* Whether all slots are filled.
|
|
35
|
+
*/
|
|
36
|
+
complete: boolean
|
|
37
|
+
/**
|
|
38
|
+
* Whether the component should ignore user interaction.
|
|
39
|
+
*/
|
|
40
|
+
disabled: boolean
|
|
41
|
+
/**
|
|
42
|
+
* The number of OTP input slots.
|
|
43
|
+
*/
|
|
44
|
+
length: number
|
|
45
|
+
/**
|
|
46
|
+
* Whether the user should be unable to change the field value.
|
|
47
|
+
*/
|
|
48
|
+
readOnly: boolean
|
|
49
|
+
/**
|
|
50
|
+
* Whether the user must enter a value before submitting a form.
|
|
51
|
+
*/
|
|
52
|
+
required: boolean
|
|
53
|
+
/**
|
|
54
|
+
* The OTP value.
|
|
55
|
+
*/
|
|
56
|
+
value: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface OtpFieldRootProps extends BaseUIComponentProps<OtpFieldRootState> {
|
|
60
|
+
/**
|
|
61
|
+
* The id of the first input element.
|
|
62
|
+
* Subsequent inputs derive their ids from it (`{id}-2`, `{id}-3`, and so on).
|
|
63
|
+
*/
|
|
64
|
+
id?: string
|
|
65
|
+
/**
|
|
66
|
+
* The input autocomplete attribute. Applied to the first slot and hidden validation input.
|
|
67
|
+
* @default 'one-time-code'
|
|
68
|
+
*/
|
|
69
|
+
autoComplete?: string
|
|
70
|
+
/**
|
|
71
|
+
* A string specifying the `form` element with which the hidden input is associated.
|
|
72
|
+
* This string's value must match the id of a `form` element in the same document.
|
|
73
|
+
*/
|
|
74
|
+
form?: string
|
|
75
|
+
/**
|
|
76
|
+
* The number of OTP input slots.
|
|
77
|
+
* Required so the root can clamp values, detect completion, and generate
|
|
78
|
+
* consistent validation markup before all slots hydrate.
|
|
79
|
+
*/
|
|
80
|
+
length: number
|
|
81
|
+
/**
|
|
82
|
+
* Whether to submit the owning form when the OTP becomes complete.
|
|
83
|
+
* @default false
|
|
84
|
+
*/
|
|
85
|
+
autoSubmit?: boolean
|
|
86
|
+
/**
|
|
87
|
+
* Whether the slot inputs should mask entered characters.
|
|
88
|
+
* Pass `type` directly to individual `OtpFieldInput` parts to use a custom input type.
|
|
89
|
+
* @default false
|
|
90
|
+
*/
|
|
91
|
+
mask?: boolean
|
|
92
|
+
/**
|
|
93
|
+
* The virtual keyboard hint applied to the slot inputs and hidden validation input.
|
|
94
|
+
*/
|
|
95
|
+
inputMode?: string
|
|
96
|
+
/**
|
|
97
|
+
* The type of input validation to apply to the OTP value.
|
|
98
|
+
* @default 'numeric'
|
|
99
|
+
*/
|
|
100
|
+
validationType?: OtpValidationType
|
|
101
|
+
/**
|
|
102
|
+
* Function that normalizes the OTP value after whitespace and `validationType` filtering.
|
|
103
|
+
* It should be idempotent because OtpField may normalize the same value more than once.
|
|
104
|
+
*/
|
|
105
|
+
normalizeValue?: (value: string) => string
|
|
106
|
+
/**
|
|
107
|
+
* Whether the user must enter a value before submitting a form.
|
|
108
|
+
* @default false
|
|
109
|
+
*/
|
|
110
|
+
required?: boolean
|
|
111
|
+
/**
|
|
112
|
+
* Whether the component should ignore user interaction.
|
|
113
|
+
* @default false
|
|
114
|
+
*/
|
|
115
|
+
disabled?: boolean
|
|
116
|
+
/**
|
|
117
|
+
* Whether the user should be unable to change the field value.
|
|
118
|
+
* @default false
|
|
119
|
+
*/
|
|
120
|
+
readOnly?: boolean
|
|
121
|
+
/**
|
|
122
|
+
* Identifies the field when a form is submitted.
|
|
123
|
+
*/
|
|
124
|
+
name?: string
|
|
125
|
+
/**
|
|
126
|
+
* The OTP value.
|
|
127
|
+
*/
|
|
128
|
+
value?: string
|
|
129
|
+
/**
|
|
130
|
+
* The uncontrolled OTP value when the component is initially rendered.
|
|
131
|
+
*/
|
|
132
|
+
defaultValue?: string
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
defineOptions({
|
|
136
|
+
name: 'OtpFieldRoot',
|
|
137
|
+
inheritAttrs: false,
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
const props = withDefaults(defineProps<OtpFieldRootProps>(), {
|
|
141
|
+
as: 'div',
|
|
142
|
+
autoComplete: 'one-time-code',
|
|
143
|
+
autoSubmit: false,
|
|
144
|
+
mask: false,
|
|
145
|
+
validationType: 'numeric',
|
|
146
|
+
required: false,
|
|
147
|
+
disabled: false,
|
|
148
|
+
readOnly: false,
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const emit = defineEmits<{
|
|
152
|
+
/**
|
|
153
|
+
* Fired when the OTP value changes.
|
|
154
|
+
*/
|
|
155
|
+
valueChange: [value: string, eventDetails: OtpFieldRootChangeEventDetails]
|
|
156
|
+
/**
|
|
157
|
+
* Fired when entered text contains characters rejected by validation or normalization.
|
|
158
|
+
*/
|
|
159
|
+
valueInvalid: [value: string, eventDetails: OtpFieldRootInvalidEventDetails]
|
|
160
|
+
/**
|
|
161
|
+
* Fired when the OTP value becomes complete.
|
|
162
|
+
*/
|
|
163
|
+
valueComplete: [value: string, eventDetails: OtpFieldRootCompleteEventDetails]
|
|
164
|
+
}>()
|
|
165
|
+
|
|
166
|
+
const attrs = useAttrs()
|
|
167
|
+
const attrsObject = attrs as Record<string, any>
|
|
168
|
+
|
|
169
|
+
const {
|
|
170
|
+
setDirty,
|
|
171
|
+
validityData,
|
|
172
|
+
disabled: fieldDisabled,
|
|
173
|
+
setFilled,
|
|
174
|
+
invalid,
|
|
175
|
+
name: fieldName,
|
|
176
|
+
state: fieldState,
|
|
177
|
+
validation,
|
|
178
|
+
validationMode,
|
|
179
|
+
setFocused,
|
|
180
|
+
setTouched,
|
|
181
|
+
} = useFieldRootContext()
|
|
182
|
+
const { clearErrors } = useFormContext()
|
|
183
|
+
const { getDescriptionProps, labelId } = useLabelableContext()
|
|
184
|
+
|
|
185
|
+
const length = computed(() => props.length)
|
|
186
|
+
const validationType = computed(() => props.validationType)
|
|
187
|
+
const normalizeValueProp = computed(() => props.normalizeValue)
|
|
188
|
+
const disabled = computed(() => fieldDisabled.value || props.disabled)
|
|
189
|
+
const readOnly = computed(() => props.readOnly)
|
|
190
|
+
const required = computed(() => props.required)
|
|
191
|
+
const mask = computed(() => props.mask)
|
|
192
|
+
const autoComplete = computed(() => props.autoComplete)
|
|
193
|
+
const formProp = computed(() => props.form)
|
|
194
|
+
|
|
195
|
+
const name = computed(() => fieldName.value ?? props.name)
|
|
196
|
+
|
|
197
|
+
const { value: valueUnwrapped, setValue: setValueUnwrapped } = useControllableState<string>({
|
|
198
|
+
controlled: () => props.value,
|
|
199
|
+
default: () => props.defaultValue ?? '',
|
|
200
|
+
name: 'OtpField',
|
|
201
|
+
state: 'value',
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
const value = computed(() =>
|
|
205
|
+
normalizeOTPValue(valueUnwrapped.value, length.value, validationType.value, normalizeValueProp.value),
|
|
206
|
+
)
|
|
207
|
+
const valueLength = computed(() => getOTPValueLength(value.value))
|
|
208
|
+
const filled = computed(() => value.value !== '')
|
|
209
|
+
|
|
210
|
+
const rootRef = ref<HTMLElement | null>(null)
|
|
211
|
+
// Wrap in a plain object so the template binding passes the Ref itself instead of
|
|
212
|
+
// the auto-unwrapped array (CompositeList needs the live Ref).
|
|
213
|
+
const inputRefsHolder = { elementsRef: ref<Array<HTMLElement | null>>([]) }
|
|
214
|
+
const inputRefs = inputRefsHolder.elementsRef
|
|
215
|
+
const validationInputRef = ref<HTMLInputElement | null>(null)
|
|
216
|
+
const firstInputRef = {
|
|
217
|
+
get value() {
|
|
218
|
+
return inputRefs.value[0] ?? null
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let pendingFocus: { index: number, value: string } | null = null
|
|
223
|
+
let pendingCompleteValue: { value: string, eventDetails: OtpFieldRootCompleteEventDetails } | null = null
|
|
224
|
+
|
|
225
|
+
const inputCount = ref(0)
|
|
226
|
+
const focusedIndex = ref(Math.min(valueLength.value, length.value - 1))
|
|
227
|
+
const focused = ref(false)
|
|
228
|
+
|
|
229
|
+
const id = useLabelableId({ id: computed(() => props.id) })
|
|
230
|
+
|
|
231
|
+
const ariaLabelledByAttr = computed(() => attrs['aria-labelledby'] as string | undefined)
|
|
232
|
+
const ariaLabelledBy = useAriaLabelledBy({
|
|
233
|
+
ariaLabelledBy: ariaLabelledByAttr,
|
|
234
|
+
labelId,
|
|
235
|
+
labelSourceRef: firstInputRef,
|
|
236
|
+
enableFallback: true,
|
|
237
|
+
labelSourceId: () => id.value ?? undefined,
|
|
238
|
+
})
|
|
239
|
+
const inputAriaLabelledBy = computed(() =>
|
|
240
|
+
ariaLabelledByAttr.value == null ? ariaLabelledBy.value : undefined,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
const ariaDescribedBy = computed(() =>
|
|
244
|
+
mergeAriaIds(
|
|
245
|
+
attrs['aria-describedby'] as string | undefined,
|
|
246
|
+
getDescriptionProps()['aria-describedby'],
|
|
247
|
+
),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
const validationConfig = computed(() => getOTPValidationConfig(validationType.value))
|
|
251
|
+
const pattern = computed(() => validationConfig.value?.slotPattern)
|
|
252
|
+
const hiddenInputPattern = computed(() => validationConfig.value?.getRootPattern(length.value))
|
|
253
|
+
const inputMode = computed(() => props.inputMode ?? validationConfig.value?.inputMode)
|
|
254
|
+
const hasValidLength = computed(() => Number.isInteger(length.value) && length.value > 0)
|
|
255
|
+
|
|
256
|
+
const activeIndex = computed(() =>
|
|
257
|
+
focused.value
|
|
258
|
+
? Math.min(focusedIndex.value, Math.max(length.value - 1, 0))
|
|
259
|
+
: Math.min(valueLength.value, length.value - 1),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
watchEffect(() => {
|
|
263
|
+
setFilled(filled.value)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
watchEffect(() => {
|
|
267
|
+
validation.setInputRef(validationInputRef.value)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
useField({
|
|
271
|
+
enabled: computed(() => !disabled.value),
|
|
272
|
+
id,
|
|
273
|
+
name,
|
|
274
|
+
commit: (v: unknown) => validation.commit(v),
|
|
275
|
+
value,
|
|
276
|
+
getValue: () => value.value,
|
|
277
|
+
controlRef: firstInputRef,
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
function focusInput(index: number) {
|
|
281
|
+
const targetIndex = Math.min(Math.max(index, 0), Math.max(inputRefs.value.length - 1, 0))
|
|
282
|
+
const target = inputRefs.value[targetIndex] as HTMLInputElement | null
|
|
283
|
+
target?.focus()
|
|
284
|
+
target?.select()
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function queueFocusInput(index: number, nextValue: string) {
|
|
288
|
+
pendingFocus = { index, value: nextValue }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function requestSubmit() {
|
|
292
|
+
let formElement: HTMLFormElement | null
|
|
293
|
+
= validationInputRef.value?.form
|
|
294
|
+
?? (inputRefs.value[0] as HTMLInputElement | null)?.form
|
|
295
|
+
?? null
|
|
296
|
+
|
|
297
|
+
if (formProp.value) {
|
|
298
|
+
const associatedElement = ownerDocument(rootRef.value)?.getElementById(formProp.value)
|
|
299
|
+
if (associatedElement?.tagName === 'FORM') {
|
|
300
|
+
formElement = associatedElement as HTMLFormElement
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (formElement && typeof formElement.requestSubmit === 'function') {
|
|
305
|
+
formElement.requestSubmit()
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function completeValue(completedValue: string, eventDetails: OtpFieldRootCompleteEventDetails) {
|
|
310
|
+
emit('valueComplete', completedValue, eventDetails)
|
|
311
|
+
|
|
312
|
+
if (props.autoSubmit) {
|
|
313
|
+
requestSubmit()
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function getCompleteEventDetails(details: OtpFieldRootChangeEventDetails) {
|
|
318
|
+
if (details.reason === REASONS.inputChange || details.reason === REASONS.inputPaste) {
|
|
319
|
+
return createGenericEventDetails(details.reason, details.event) as OtpFieldRootCompleteEventDetails
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return null
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function setValue(nextValue: string, details: OtpFieldRootChangeEventDetails): string | null {
|
|
326
|
+
const currentValue = value.value
|
|
327
|
+
const normalizedValue = normalizeOTPValue(
|
|
328
|
+
nextValue,
|
|
329
|
+
length.value,
|
|
330
|
+
validationType.value,
|
|
331
|
+
normalizeValueProp.value,
|
|
332
|
+
)
|
|
333
|
+
const normalizedValueLength = getOTPValueLength(normalizedValue)
|
|
334
|
+
const completeEventDetails
|
|
335
|
+
= normalizedValueLength === length.value
|
|
336
|
+
&& (getOTPValueLength(currentValue) !== length.value || details.reason === REASONS.inputPaste)
|
|
337
|
+
? getCompleteEventDetails(details)
|
|
338
|
+
: null
|
|
339
|
+
|
|
340
|
+
if (normalizedValue === currentValue) {
|
|
341
|
+
if (completeEventDetails != null) {
|
|
342
|
+
completeValue(normalizedValue, completeEventDetails)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return null
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
emit('valueChange', normalizedValue, details)
|
|
349
|
+
|
|
350
|
+
if (details.isCanceled) {
|
|
351
|
+
return null
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
setValueUnwrapped(normalizedValue)
|
|
355
|
+
|
|
356
|
+
if (completeEventDetails != null) {
|
|
357
|
+
pendingCompleteValue = { value: normalizedValue, eventDetails: completeEventDetails }
|
|
358
|
+
}
|
|
359
|
+
else if (normalizedValueLength !== length.value) {
|
|
360
|
+
pendingCompleteValue = null
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return normalizedValue
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function reportValueInvalid(invalidValue: string, details: OtpFieldRootInvalidEventDetails) {
|
|
367
|
+
emit('valueInvalid', invalidValue, details)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function handleInputFocus(index: number, event: FocusEvent) {
|
|
371
|
+
if (index > valueLength.value) {
|
|
372
|
+
focusInput(Math.min(valueLength.value, length.value - 1))
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
focusedIndex.value = index
|
|
377
|
+
focused.value = true
|
|
378
|
+
setFocused(true)
|
|
379
|
+
;(event.currentTarget as HTMLInputElement).select()
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function handleInputBlur(event: FocusEvent) {
|
|
383
|
+
if (contains(rootRef.value, event.relatedTarget as Element | null)) {
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
setTouched(true)
|
|
388
|
+
focused.value = false
|
|
389
|
+
setFocused(false)
|
|
390
|
+
|
|
391
|
+
if (validationMode.value === 'onBlur') {
|
|
392
|
+
validation.commit(value.value)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function getInputId(index: number) {
|
|
397
|
+
if (id.value == null) {
|
|
398
|
+
return undefined
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return index === 0 ? id.value : `${id.value}-${index + 1}`
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
useValueChanged(value, () => {
|
|
405
|
+
clearErrors(name.value)
|
|
406
|
+
setDirty(value.value !== validityData.value.initialValue)
|
|
407
|
+
|
|
408
|
+
void validation.commit(value.value, true)
|
|
409
|
+
|
|
410
|
+
if (pendingCompleteValue != null) {
|
|
411
|
+
const pending = pendingCompleteValue
|
|
412
|
+
pendingCompleteValue = null
|
|
413
|
+
|
|
414
|
+
if (pending.value === value.value) {
|
|
415
|
+
completeValue(value.value, pending.eventDetails)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (pendingFocus != null) {
|
|
420
|
+
const pending = pendingFocus
|
|
421
|
+
pendingFocus = null
|
|
422
|
+
|
|
423
|
+
if (pending.value === value.value) {
|
|
424
|
+
focusInput(pending.index)
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
const state = computed<OtpFieldRootState>(() => ({
|
|
430
|
+
...fieldState.value,
|
|
431
|
+
complete: valueLength.value === length.value,
|
|
432
|
+
disabled: disabled.value,
|
|
433
|
+
filled: filled.value,
|
|
434
|
+
focused: focused.value,
|
|
435
|
+
length: length.value,
|
|
436
|
+
readOnly: readOnly.value,
|
|
437
|
+
required: required.value,
|
|
438
|
+
value: value.value,
|
|
439
|
+
}))
|
|
440
|
+
|
|
441
|
+
provide(otpFieldRootContextKey, {
|
|
442
|
+
activeIndex,
|
|
443
|
+
autoComplete,
|
|
444
|
+
disabled,
|
|
445
|
+
form: formProp,
|
|
446
|
+
focusInput,
|
|
447
|
+
queueFocusInput,
|
|
448
|
+
getInputId,
|
|
449
|
+
handleInputBlur,
|
|
450
|
+
handleInputFocus,
|
|
451
|
+
inputMode,
|
|
452
|
+
inputAriaLabelledBy,
|
|
453
|
+
invalid,
|
|
454
|
+
length,
|
|
455
|
+
mask,
|
|
456
|
+
pattern,
|
|
457
|
+
reportValueInvalid,
|
|
458
|
+
readOnly,
|
|
459
|
+
required,
|
|
460
|
+
normalizeValue: normalizeValueProp,
|
|
461
|
+
setValue,
|
|
462
|
+
state,
|
|
463
|
+
validationType,
|
|
464
|
+
value,
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
const rootProps = computed(() => mergeProps(
|
|
468
|
+
attrsObject,
|
|
469
|
+
{
|
|
470
|
+
'role': 'group',
|
|
471
|
+
'aria-describedby': ariaDescribedBy.value,
|
|
472
|
+
'aria-labelledby': ariaLabelledBy.value,
|
|
473
|
+
},
|
|
474
|
+
))
|
|
475
|
+
|
|
476
|
+
const {
|
|
477
|
+
tag,
|
|
478
|
+
mergedProps,
|
|
479
|
+
renderless,
|
|
480
|
+
ref: renderRef,
|
|
481
|
+
} = useRenderElement({
|
|
482
|
+
componentProps: props,
|
|
483
|
+
state,
|
|
484
|
+
props: rootProps,
|
|
485
|
+
stateAttributesMapping: rootStateAttributesMapping,
|
|
486
|
+
defaultTagName: 'div',
|
|
487
|
+
ref: rootRef,
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
const hiddenInputProps = computed<Record<string, any>>(() => ({
|
|
491
|
+
...validation.getValidationProps(),
|
|
492
|
+
'type': 'text',
|
|
493
|
+
'id': id.value && name.value == null ? `${id.value}-hidden-input` : undefined,
|
|
494
|
+
'form': formProp.value,
|
|
495
|
+
'name': name.value,
|
|
496
|
+
'value': value.value,
|
|
497
|
+
'autocomplete': autoComplete.value,
|
|
498
|
+
'inputmode': inputMode.value,
|
|
499
|
+
'minlength': length.value,
|
|
500
|
+
'maxlength': length.value,
|
|
501
|
+
'pattern': hiddenInputPattern.value,
|
|
502
|
+
'disabled': disabled.value,
|
|
503
|
+
'readonly': readOnly.value,
|
|
504
|
+
'required': required.value,
|
|
505
|
+
'aria-hidden': true,
|
|
506
|
+
'tabindex': -1,
|
|
507
|
+
'style': name.value ? visuallyHiddenInput : visuallyHidden,
|
|
508
|
+
}))
|
|
509
|
+
|
|
510
|
+
function handleHiddenFocus() {
|
|
511
|
+
focusInput(0)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function handleHiddenInput(event: Event) {
|
|
515
|
+
const target = event.target as HTMLInputElement
|
|
516
|
+
if (event.defaultPrevented || disabled.value || readOnly.value) {
|
|
517
|
+
return
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const rawValue = target.value
|
|
521
|
+
const [normalizedValue, didRejectCharacters] = normalizeOTPValueWithDetails(
|
|
522
|
+
rawValue,
|
|
523
|
+
length.value,
|
|
524
|
+
validationType.value,
|
|
525
|
+
normalizeValueProp.value,
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
if (didRejectCharacters) {
|
|
529
|
+
reportValueInvalid(rawValue, createGenericEventDetails(REASONS.inputChange, event))
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const committedValue = setValue(
|
|
533
|
+
normalizedValue,
|
|
534
|
+
createChangeEventDetails(REASONS.inputChange, event),
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
if (committedValue != null && committedValue !== '') {
|
|
538
|
+
queueFocusInput(getOTPValueLength(committedValue) - 1, committedValue)
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function mergeAriaIds(...values: Array<string | undefined>) {
|
|
543
|
+
const ids = values.flatMap(v => v?.split(/\s+/).filter(Boolean) ?? [])
|
|
544
|
+
return ids.length > 0 ? Array.from(new Set(ids)).join(' ') : undefined
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
548
|
+
watchEffect(() => {
|
|
549
|
+
const len = length.value
|
|
550
|
+
if (!Number.isInteger(len) || len <= 0) {
|
|
551
|
+
warn(`<OtpFieldRoot> \`length\` must be a positive integer. Received \`length={${String(len)}}\`.`)
|
|
552
|
+
return
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (inputCount.value !== 0 && inputCount.value !== len) {
|
|
556
|
+
warn(
|
|
557
|
+
`<OtpFieldRoot> \`length\` must match the number of rendered <OtpFieldInput /> parts. `
|
|
558
|
+
+ `Received \`length={${len}}\` but rendered ${inputCount.value} input${inputCount.value === 1 ? '' : 's'}.`,
|
|
559
|
+
)
|
|
560
|
+
}
|
|
561
|
+
})
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function handleMapChange(map: Map<Element, unknown>) {
|
|
565
|
+
inputCount.value = map.size
|
|
566
|
+
}
|
|
567
|
+
</script>
|
|
568
|
+
|
|
569
|
+
<template>
|
|
570
|
+
<CompositeList :elements-ref="inputRefsHolder.elementsRef" :on-map-change="handleMapChange">
|
|
571
|
+
<slot v-if="renderless" :ref="renderRef" :props="mergedProps" :state="state" />
|
|
572
|
+
<component :is="tag" v-else :ref="renderRef" v-bind="mergedProps">
|
|
573
|
+
<slot :state="state" />
|
|
574
|
+
</component>
|
|
575
|
+
<input
|
|
576
|
+
v-if="hasValidLength"
|
|
577
|
+
ref="validationInputRef"
|
|
578
|
+
v-bind="hiddenInputProps"
|
|
579
|
+
@input="handleHiddenInput"
|
|
580
|
+
@focus="handleHiddenFocus"
|
|
581
|
+
>
|
|
582
|
+
</CompositeList>
|
|
583
|
+
</template>
|
|
@@ -0,0 +1,81 @@
|
|
|
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 { OtpFieldInputState } from '../input/OtpFieldInput.vue'
|
|
8
|
+
import type { OtpValidationType } from '../utils/otp'
|
|
9
|
+
import type { OtpFieldRootState } from './OtpFieldRoot.vue'
|
|
10
|
+
import { inject } from 'vue'
|
|
11
|
+
|
|
12
|
+
export type OtpFieldRootChangeEventReason
|
|
13
|
+
= | typeof REASONS.inputChange
|
|
14
|
+
| typeof REASONS.inputClear
|
|
15
|
+
| typeof REASONS.inputPaste
|
|
16
|
+
| typeof REASONS.keyboard
|
|
17
|
+
export type OtpFieldRootChangeEventDetails
|
|
18
|
+
= BaseUIChangeEventDetails<OtpFieldRootChangeEventReason>
|
|
19
|
+
|
|
20
|
+
export type OtpFieldRootInvalidEventReason = typeof REASONS.inputChange | typeof REASONS.inputPaste
|
|
21
|
+
export type OtpFieldRootInvalidEventDetails
|
|
22
|
+
= BaseUIGenericEventDetails<OtpFieldRootInvalidEventReason>
|
|
23
|
+
|
|
24
|
+
export type OtpFieldRootCompleteEventReason
|
|
25
|
+
= | typeof REASONS.inputChange
|
|
26
|
+
| typeof REASONS.inputPaste
|
|
27
|
+
export type OtpFieldRootCompleteEventDetails
|
|
28
|
+
= BaseUIGenericEventDetails<OtpFieldRootCompleteEventReason>
|
|
29
|
+
|
|
30
|
+
export interface OtpFieldRootContext {
|
|
31
|
+
activeIndex: ComputedRef<number>
|
|
32
|
+
autoComplete: ComputedRef<string | undefined>
|
|
33
|
+
disabled: ComputedRef<boolean>
|
|
34
|
+
form: ComputedRef<string | undefined>
|
|
35
|
+
focusInput: (index: number) => void
|
|
36
|
+
queueFocusInput: (index: number, value: string) => void
|
|
37
|
+
getInputId: (index: number) => string | undefined
|
|
38
|
+
handleInputBlur: (event: FocusEvent) => void
|
|
39
|
+
handleInputFocus: (index: number, event: FocusEvent) => void
|
|
40
|
+
inputMode: ComputedRef<string | undefined>
|
|
41
|
+
inputAriaLabelledBy: ComputedRef<string | undefined>
|
|
42
|
+
invalid: Readonly<Ref<boolean | undefined>>
|
|
43
|
+
length: ComputedRef<number>
|
|
44
|
+
mask: ComputedRef<boolean>
|
|
45
|
+
pattern: ComputedRef<string | undefined>
|
|
46
|
+
reportValueInvalid: (value: string, details: OtpFieldRootInvalidEventDetails) => void
|
|
47
|
+
readOnly: ComputedRef<boolean>
|
|
48
|
+
required: ComputedRef<boolean>
|
|
49
|
+
normalizeValue: ComputedRef<((value: string) => string) | undefined>
|
|
50
|
+
setValue: (value: string, details: OtpFieldRootChangeEventDetails) => string | null
|
|
51
|
+
state: ComputedRef<OtpFieldRootState>
|
|
52
|
+
validationType: ComputedRef<OtpValidationType>
|
|
53
|
+
value: ComputedRef<string>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const otpFieldRootContextKey: InjectionKey<OtpFieldRootContext> = Symbol('OtpFieldRootContext')
|
|
57
|
+
|
|
58
|
+
export function useOtpFieldRootContext() {
|
|
59
|
+
const context = inject(otpFieldRootContextKey, undefined)
|
|
60
|
+
|
|
61
|
+
if (context === undefined) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
'Base UI Vue: OtpFieldRootContext is missing. OtpField parts must be placed within <OtpFieldRoot>.',
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return context
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function getOtpFieldInputState(
|
|
71
|
+
state: OtpFieldRootState,
|
|
72
|
+
value: string,
|
|
73
|
+
index: number,
|
|
74
|
+
): OtpFieldInputState {
|
|
75
|
+
return {
|
|
76
|
+
...state,
|
|
77
|
+
value,
|
|
78
|
+
index,
|
|
79
|
+
filled: value !== '',
|
|
80
|
+
}
|
|
81
|
+
}
|