@coreui/react-pro 5.22.0 → 5.23.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 (40) hide show
  1. package/README.md +1 -1
  2. package/dist/cjs/components/autocomplete/CAutocomplete.js +2 -2
  3. package/dist/cjs/components/autocomplete/CAutocomplete.js.map +1 -1
  4. package/dist/cjs/components/index.d.ts +1 -0
  5. package/dist/cjs/components/one-time-password-input/COneTimePassword.d.ts +73 -0
  6. package/dist/cjs/components/one-time-password-input/COneTimePassword.js +178 -0
  7. package/dist/cjs/components/one-time-password-input/COneTimePassword.js.map +1 -0
  8. package/dist/cjs/components/one-time-password-input/COneTimePasswordInput.d.ts +8 -0
  9. package/dist/cjs/components/one-time-password-input/COneTimePasswordInput.js +14 -0
  10. package/dist/cjs/components/one-time-password-input/COneTimePasswordInput.js.map +1 -0
  11. package/dist/cjs/components/one-time-password-input/index.d.ts +5 -0
  12. package/dist/cjs/components/one-time-password-input/utils.d.ts +2 -0
  13. package/dist/cjs/components/one-time-password-input/utils.js +28 -0
  14. package/dist/cjs/components/one-time-password-input/utils.js.map +1 -0
  15. package/dist/cjs/index.js +4 -0
  16. package/dist/cjs/index.js.map +1 -1
  17. package/dist/esm/components/autocomplete/CAutocomplete.js +2 -2
  18. package/dist/esm/components/autocomplete/CAutocomplete.js.map +1 -1
  19. package/dist/esm/components/index.d.ts +1 -0
  20. package/dist/esm/components/one-time-password-input/COneTimePassword.d.ts +73 -0
  21. package/dist/esm/components/one-time-password-input/COneTimePassword.js +176 -0
  22. package/dist/esm/components/one-time-password-input/COneTimePassword.js.map +1 -0
  23. package/dist/esm/components/one-time-password-input/COneTimePasswordInput.d.ts +8 -0
  24. package/dist/esm/components/one-time-password-input/COneTimePasswordInput.js +12 -0
  25. package/dist/esm/components/one-time-password-input/COneTimePasswordInput.js.map +1 -0
  26. package/dist/esm/components/one-time-password-input/index.d.ts +5 -0
  27. package/dist/esm/components/one-time-password-input/utils.d.ts +2 -0
  28. package/dist/esm/components/one-time-password-input/utils.js +25 -0
  29. package/dist/esm/components/one-time-password-input/utils.js.map +1 -0
  30. package/dist/esm/index.js +2 -0
  31. package/dist/esm/index.js.map +1 -1
  32. package/package.json +9 -9
  33. package/src/components/autocomplete/CAutocomplete.tsx +18 -16
  34. package/src/components/index.ts +1 -0
  35. package/src/components/one-time-password-input/COneTimePassword.tsx +462 -0
  36. package/src/components/one-time-password-input/COneTimePasswordInput.tsx +25 -0
  37. package/src/components/one-time-password-input/__tests__/COneTimePassword.spec.tsx +361 -0
  38. package/src/components/one-time-password-input/__tests__/__snapshots__/COneTimePassword.spec.tsx.snap +130 -0
  39. package/src/components/one-time-password-input/index.ts +6 -0
  40. package/src/components/one-time-password-input/utils.ts +23 -0
@@ -0,0 +1,462 @@
1
+ import React, {
2
+ ChangeEvent,
3
+ Children,
4
+ ClipboardEvent,
5
+ cloneElement,
6
+ FocusEvent,
7
+ forwardRef,
8
+ HTMLAttributes,
9
+ isValidElement,
10
+ KeyboardEvent,
11
+ useCallback,
12
+ useEffect,
13
+ useMemo,
14
+ useRef,
15
+ useState,
16
+ ReactElement,
17
+ } from 'react'
18
+ import PropTypes from 'prop-types'
19
+ import classNames from 'classnames'
20
+
21
+ import { CFormControlWrapper, CFormControlWrapperProps } from '../form/CFormControlWrapper'
22
+ import { COneTimePasswordInput, COneTimePasswordInputProps } from './COneTimePasswordInput'
23
+
24
+ import { isValidInput, extractValidChars } from './utils'
25
+ import { getNextActiveElement, isRTL } from '../../utils'
26
+
27
+ type ComponentWithDisplayName = {
28
+ displayName?: string
29
+ }
30
+
31
+ export interface COneTimePasswordProps
32
+ extends CFormControlWrapperProps,
33
+ Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
34
+ /**
35
+ * Function to generate aria-label for each input field. Receives current index (0-based) and total number of inputs.
36
+ */
37
+ ariaLabel?: (index: number, total: number) => string
38
+
39
+ /**
40
+ * Automatically submit the form when all one time password fields are filled.
41
+ */
42
+ autoSubmit?: boolean
43
+
44
+ /**
45
+ * A string of all className you want applied to the React.js OTP component.
46
+ */
47
+ className?: string
48
+
49
+ /**
50
+ * Initial value for uncontrolled React.js one time password input.
51
+ */
52
+ defaultValue?: string | number
53
+
54
+ /**
55
+ * Disable all one time password (OTP) input fields.
56
+ */
57
+ disabled?: boolean
58
+
59
+ /**
60
+ * ID attribute for the hidden input field.
61
+ */
62
+ id?: string
63
+
64
+ /**
65
+ * Enforce sequential input (users must fill fields in order).
66
+ */
67
+ linear?: boolean
68
+
69
+ /**
70
+ * Show input as password (masked characters).
71
+ */
72
+ masked?: boolean
73
+
74
+ /**
75
+ * Name attribute for the hidden input field.
76
+ */
77
+ name?: string
78
+
79
+ /**
80
+ * Callback triggered when the React.js one time password (OTP) value changes.
81
+ */
82
+ onChange?: (value: string) => void
83
+
84
+ /**
85
+ * Callback triggered when all React.js one time password (OTP) fields are filled.
86
+ */
87
+ onComplete?: (value: string) => void
88
+
89
+ /**
90
+ * Placeholder text for input fields. Single character applies to all fields, longer strings apply character-by-character.
91
+ */
92
+ placeholder?: string
93
+
94
+ /**
95
+ * Make React.js OTP input component read-only.
96
+ */
97
+ readOnly?: boolean
98
+
99
+ /**
100
+ * Makes the input field required for form validation.
101
+ */
102
+ required?: boolean
103
+
104
+ /**
105
+ * Sets the visual size of the React.js one time password (OTP) input. Use 'sm' for small or 'lg' for large input fields.
106
+ */
107
+ size?: 'sm' | 'lg'
108
+
109
+ /**
110
+ * Input validation type: 'number' for digits only, or 'text' for free text.
111
+ */
112
+ type?: 'number' | 'text'
113
+
114
+ /**
115
+ * Current value for controlled OTP input.
116
+ */
117
+ value?: string | number
118
+ }
119
+
120
+ export const COneTimePassword = forwardRef<HTMLDivElement, COneTimePasswordProps>(
121
+ (
122
+ {
123
+ ariaLabel = (index: number, total: number) => `Digit ${index + 1} of ${total}`,
124
+ autoSubmit = false,
125
+ children,
126
+ className,
127
+ defaultValue,
128
+ disabled = false,
129
+ feedback,
130
+ feedbackInvalid,
131
+ feedbackValid,
132
+ floatingClassName,
133
+ floatingLabel,
134
+ id,
135
+ invalid,
136
+ label,
137
+ linear = true,
138
+ masked = false,
139
+ name,
140
+ onChange,
141
+ onComplete,
142
+ placeholder,
143
+ readOnly = false,
144
+ required = false,
145
+ size,
146
+ text,
147
+ tooltipFeedback,
148
+ type = 'number',
149
+ valid,
150
+ value,
151
+ ...rest
152
+ },
153
+ ref
154
+ ) => {
155
+ const inputRefs = useRef<(HTMLInputElement | null)[]>([])
156
+ const hiddenInputRef = useRef<HTMLInputElement>(null)
157
+
158
+ // Count valid OTP input children
159
+ const inputCount = useMemo(() => {
160
+ return Children.count(children)
161
+ }, [children])
162
+
163
+ const [inputValues, setInputValues] = useState<string[]>(() => {
164
+ const initialValue = String(value ?? defaultValue ?? '')
165
+ return Array.from({ length: inputCount }, (_, i) => initialValue[i] || '')
166
+ })
167
+
168
+ // Update input values when value prop changes (controlled mode)
169
+ useEffect(() => {
170
+ if (value !== undefined) {
171
+ const valueString = String(value)
172
+ setInputValues(Array.from({ length: inputCount }, (_, i) => valueString[i] || ''))
173
+ }
174
+ }, [value, inputCount])
175
+
176
+ // Update hidden input and trigger events
177
+ const updateValue = useCallback(
178
+ (newValues: string[]) => {
179
+ const newValue = newValues.join('')
180
+
181
+ if (hiddenInputRef.current) {
182
+ hiddenInputRef.current.value = newValue
183
+ }
184
+
185
+ onChange?.(newValue)
186
+
187
+ if (newValue.length === inputCount) {
188
+ onComplete?.(newValue)
189
+
190
+ if (autoSubmit) {
191
+ const form = hiddenInputRef.current?.closest('form')
192
+ if (form && typeof form.requestSubmit === 'function') {
193
+ form.requestSubmit()
194
+ }
195
+ }
196
+ }
197
+ },
198
+ [autoSubmit, onChange, onComplete, inputCount]
199
+ )
200
+
201
+ const handleInputChange = useCallback(
202
+ (index: number, event: ChangeEvent<HTMLInputElement>) => {
203
+ const inputValue = event.target.value
204
+
205
+ if (inputValue.length === 1 && !isValidInput(inputValue, type)) {
206
+ return
207
+ }
208
+
209
+ const newValues = [...inputValues]
210
+ newValues[index] = inputValue.length === 1 ? inputValue : ''
211
+
212
+ setInputValues(newValues)
213
+ updateValue(newValues)
214
+
215
+ if (inputValue.length === 1) {
216
+ const nextInput = getNextActiveElement(
217
+ inputRefs.current as HTMLInputElement[],
218
+ event.target,
219
+ true,
220
+ false
221
+ )
222
+
223
+ nextInput?.focus()
224
+ }
225
+ },
226
+ [inputValues, type, updateValue]
227
+ )
228
+
229
+ const handleInputFocus = useCallback(
230
+ (event: FocusEvent<HTMLInputElement>) => {
231
+ const { target } = event
232
+
233
+ if (target.value) {
234
+ setTimeout(() => {
235
+ target.select()
236
+ }, 0)
237
+ return
238
+ }
239
+
240
+ if (linear) {
241
+ const firstEmptyInput = inputRefs.current.find((input) => !input?.value)
242
+ if (firstEmptyInput && firstEmptyInput !== target) {
243
+ firstEmptyInput.focus()
244
+ }
245
+ }
246
+ },
247
+ [linear]
248
+ )
249
+
250
+ const handleKeyDown = useCallback(
251
+ (event: KeyboardEvent<HTMLInputElement>) => {
252
+ const { key, target } = event
253
+
254
+ if (key === 'Backspace' && (target as HTMLInputElement).value === '') {
255
+ const newValues = [...inputValues]
256
+ const prevInput = getNextActiveElement(
257
+ inputRefs.current as HTMLInputElement[],
258
+ target as HTMLInputElement,
259
+ false,
260
+ false
261
+ )
262
+
263
+ if (prevInput) {
264
+ const prevIndex = inputRefs.current.indexOf(prevInput as HTMLInputElement)
265
+ if (prevIndex !== -1) {
266
+ newValues[prevIndex] = ''
267
+ setInputValues(newValues)
268
+ updateValue(newValues)
269
+ prevInput.focus()
270
+ }
271
+ }
272
+
273
+ return
274
+ }
275
+
276
+ if (key === 'ArrowRight') {
277
+ if (linear && (target as HTMLInputElement).value === '') {
278
+ return
279
+ }
280
+
281
+ const shouldMoveNext = !isRTL(target as HTMLInputElement)
282
+ const nextInput = getNextActiveElement(
283
+ inputRefs.current as HTMLInputElement[],
284
+ target as HTMLInputElement,
285
+ shouldMoveNext,
286
+ false
287
+ )
288
+ nextInput?.focus()
289
+ return
290
+ }
291
+
292
+ if (key === 'ArrowLeft') {
293
+ const shouldMoveNext = isRTL(target as HTMLInputElement)
294
+ const prevInput = getNextActiveElement(
295
+ inputRefs.current as HTMLInputElement[],
296
+ target as HTMLInputElement,
297
+ shouldMoveNext,
298
+ false
299
+ )
300
+ prevInput?.focus()
301
+ }
302
+ },
303
+ [inputValues, linear, updateValue]
304
+ )
305
+
306
+ const handlePaste = useCallback(
307
+ (index: number, event: ClipboardEvent<HTMLInputElement>) => {
308
+ event.preventDefault()
309
+ const pastedData = event.clipboardData.getData('text')
310
+ const validChars = extractValidChars(pastedData, type)
311
+
312
+ if (!validChars) return
313
+
314
+ const newValues = [...inputValues]
315
+ const startIndex = index
316
+
317
+ for (let i = 0; i < validChars.length && startIndex + i < inputCount; i++) {
318
+ newValues[startIndex + i] = validChars[i]
319
+ }
320
+
321
+ setInputValues(newValues)
322
+ updateValue(newValues)
323
+
324
+ // Focus the next empty input or the last filled input
325
+ const nextEmptyIndex = startIndex + validChars.length
326
+ if (nextEmptyIndex < inputCount) {
327
+ inputRefs.current[nextEmptyIndex]?.focus()
328
+ } else {
329
+ inputRefs.current.at(-1)?.focus()
330
+ }
331
+ },
332
+ [inputValues, type, updateValue, inputCount]
333
+ )
334
+
335
+ let inputIndex = 0
336
+
337
+ return (
338
+ <CFormControlWrapper
339
+ describedby={rest['aria-describedby']}
340
+ feedback={feedback}
341
+ feedbackInvalid={feedbackInvalid}
342
+ feedbackValid={feedbackValid}
343
+ floatingClassName={floatingClassName}
344
+ floatingLabel={floatingLabel}
345
+ id={id}
346
+ invalid={invalid}
347
+ label={label}
348
+ text={text}
349
+ tooltipFeedback={tooltipFeedback}
350
+ valid={valid}
351
+ >
352
+ <div
353
+ className={classNames(
354
+ 'form-otp',
355
+ {
356
+ [`form-otp-${size}`]: size,
357
+ },
358
+ className
359
+ )}
360
+ role="group"
361
+ ref={ref}
362
+ {...rest}
363
+ >
364
+ {Children.map(children, (child, index) => {
365
+ if (
366
+ !isValidElement(child) ||
367
+ (child.type as ComponentWithDisplayName)?.displayName !== 'COneTimePasswordInput'
368
+ ) {
369
+ return child
370
+ }
371
+
372
+ const inputChild = child as ReactElement<
373
+ COneTimePasswordInputProps & React.RefAttributes<HTMLInputElement>,
374
+ typeof COneTimePasswordInput
375
+ >
376
+ const currentInputIndex = inputIndex++
377
+
378
+ return cloneElement(inputChild, {
379
+ ...inputChild.props,
380
+ type: masked ? 'password' : 'text',
381
+ className: classNames(
382
+ {
383
+ 'is-invalid': invalid,
384
+ 'is-valid': valid,
385
+ },
386
+ inputChild.props.className
387
+ ),
388
+ id: inputChild.props.id || (id ? `${id}-${currentInputIndex}` : undefined),
389
+ name: inputChild.props.name || (name ? `${name}-${currentInputIndex}` : undefined),
390
+ placeholder:
391
+ inputChild.props.placeholder ||
392
+ (placeholder && placeholder.length > 1
393
+ ? placeholder[currentInputIndex]
394
+ : placeholder),
395
+ value: inputValues[currentInputIndex] || '',
396
+ tabIndex: currentInputIndex === 0 ? 0 : inputValues[currentInputIndex - 1] ? 0 : -1,
397
+ disabled: disabled || inputChild.props.disabled,
398
+ readOnly: readOnly || inputChild.props.readOnly,
399
+ required: required || inputChild.props.required,
400
+ 'aria-label':
401
+ inputChild.props['aria-label'] || ariaLabel(currentInputIndex, inputCount),
402
+
403
+ inputMode: type === 'number' ? 'numeric' : 'text',
404
+ pattern: type === 'number' ? '[0-9]*' : '.*',
405
+ onInput: (event: React.ChangeEvent<HTMLInputElement>) => {
406
+ handleInputChange(currentInputIndex, event)
407
+ inputChild.props.onChange?.(event)
408
+ },
409
+ onFocus: (event: React.FocusEvent<HTMLInputElement>) => {
410
+ handleInputFocus(event)
411
+ inputChild.props.onFocus?.(event)
412
+ },
413
+ onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => {
414
+ handleKeyDown(currentInputIndex, event)
415
+ inputChild.props.onKeyDown?.(event)
416
+ },
417
+ onPaste: (event: React.ClipboardEvent<HTMLInputElement>) => {
418
+ handlePaste(index, event)
419
+ inputChild.props.onPaste?.(event)
420
+ },
421
+ ref: (el: HTMLInputElement | null) => {
422
+ inputRefs.current[currentInputIndex] = el
423
+ },
424
+ })
425
+ })}
426
+ <input
427
+ type="hidden"
428
+ id={id}
429
+ name={name}
430
+ value={inputValues.join('')}
431
+ disabled={disabled}
432
+ ref={hiddenInputRef}
433
+ />
434
+ </div>
435
+ </CFormControlWrapper>
436
+ )
437
+ }
438
+ )
439
+
440
+ COneTimePassword.propTypes = {
441
+ ariaLabel: PropTypes.func,
442
+ autoSubmit: PropTypes.bool,
443
+ children: PropTypes.node,
444
+ className: PropTypes.string,
445
+ defaultValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
446
+ disabled: PropTypes.bool,
447
+ id: PropTypes.string,
448
+ linear: PropTypes.bool,
449
+ masked: PropTypes.bool,
450
+ name: PropTypes.string,
451
+ onChange: PropTypes.func,
452
+ onComplete: PropTypes.func,
453
+ placeholder: PropTypes.string,
454
+ readOnly: PropTypes.bool,
455
+ required: PropTypes.bool,
456
+ size: PropTypes.oneOf(['sm', 'lg']),
457
+ type: PropTypes.oneOf(['number', 'text']),
458
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
459
+ ...CFormControlWrapper.propTypes,
460
+ }
461
+
462
+ COneTimePassword.displayName = 'COneTimePassword'
@@ -0,0 +1,25 @@
1
+ import React, { forwardRef, InputHTMLAttributes } from 'react'
2
+ import classNames from 'classnames'
3
+
4
+ export interface COneTimePasswordInputProps extends InputHTMLAttributes<HTMLInputElement> {
5
+ /**
6
+ * A string of all className you want applied to the component.
7
+ */
8
+ className?: string
9
+ }
10
+
11
+ export const COneTimePasswordInput = forwardRef<HTMLInputElement, COneTimePasswordInputProps>(
12
+ ({ className, ...rest }, ref) => {
13
+ return (
14
+ <input
15
+ className={classNames('form-otp-control', className)}
16
+ maxLength={1}
17
+ autoComplete="off"
18
+ ref={ref}
19
+ {...rest}
20
+ />
21
+ )
22
+ }
23
+ )
24
+
25
+ COneTimePasswordInput.displayName = 'COneTimePasswordInput'