@cloud-ru/uikit-product-fields-predefined 3.0.0 → 3.0.1-preview-d01a0cca.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 (90) hide show
  1. package/README.md +290 -12
  2. package/dist/cjs/components/FieldCode/FieldCode.d.ts +35 -0
  3. package/dist/cjs/components/FieldCode/FieldCode.js +48 -0
  4. package/dist/cjs/components/FieldCode/components/Cell/Cell.d.ts +7 -0
  5. package/dist/cjs/components/FieldCode/components/Cell/Cell.js +27 -0
  6. package/dist/cjs/components/FieldCode/components/Cell/index.d.ts +1 -0
  7. package/dist/cjs/components/FieldCode/components/Cell/index.js +17 -0
  8. package/dist/cjs/components/FieldCode/components/Cell/styles.module.css +19 -0
  9. package/dist/cjs/components/FieldCode/components/ResendCode/ResendCode.d.ts +8 -0
  10. package/dist/cjs/components/FieldCode/components/ResendCode/ResendCode.js +28 -0
  11. package/dist/cjs/components/FieldCode/components/ResendCode/index.d.ts +1 -0
  12. package/dist/cjs/components/FieldCode/components/ResendCode/index.js +17 -0
  13. package/dist/cjs/components/FieldCode/components/ResendCode/utils.d.ts +1 -0
  14. package/dist/cjs/components/FieldCode/components/ResendCode/utils.js +8 -0
  15. package/dist/cjs/components/FieldCode/components/index.d.ts +2 -0
  16. package/dist/cjs/components/FieldCode/components/index.js +18 -0
  17. package/dist/cjs/components/FieldCode/constants.d.ts +14 -0
  18. package/dist/cjs/components/FieldCode/constants.js +10 -0
  19. package/dist/cjs/components/FieldCode/hooks/index.d.ts +4 -0
  20. package/dist/cjs/components/FieldCode/hooks/index.js +20 -0
  21. package/dist/cjs/components/FieldCode/hooks/useCodeInput.d.ts +22 -0
  22. package/dist/cjs/components/FieldCode/hooks/useCodeInput.js +98 -0
  23. package/dist/cjs/components/FieldCode/hooks/useFieldCodeValidate.d.ts +8 -0
  24. package/dist/cjs/components/FieldCode/hooks/useFieldCodeValidate.js +24 -0
  25. package/dist/cjs/components/FieldCode/hooks/useFieldHelpers.d.ts +13 -0
  26. package/dist/cjs/components/FieldCode/hooks/useFieldHelpers.js +34 -0
  27. package/dist/cjs/components/FieldCode/hooks/useFocusCell.d.ts +5 -0
  28. package/dist/cjs/components/FieldCode/hooks/useFocusCell.js +22 -0
  29. package/dist/cjs/components/FieldCode/index.d.ts +2 -0
  30. package/dist/cjs/components/FieldCode/index.js +20 -0
  31. package/dist/cjs/components/FieldCode/styles.module.css +37 -0
  32. package/dist/cjs/components/FieldCode/utils.d.ts +6 -0
  33. package/dist/cjs/components/FieldCode/utils.js +21 -0
  34. package/dist/cjs/components/index.d.ts +1 -0
  35. package/dist/cjs/components/index.js +1 -0
  36. package/dist/esm/components/FieldCode/FieldCode.d.ts +35 -0
  37. package/dist/esm/components/FieldCode/FieldCode.js +41 -0
  38. package/dist/esm/components/FieldCode/components/Cell/Cell.d.ts +7 -0
  39. package/dist/esm/components/FieldCode/components/Cell/Cell.js +21 -0
  40. package/dist/esm/components/FieldCode/components/Cell/index.d.ts +1 -0
  41. package/dist/esm/components/FieldCode/components/Cell/index.js +1 -0
  42. package/dist/esm/components/FieldCode/components/Cell/styles.module.css +19 -0
  43. package/dist/esm/components/FieldCode/components/ResendCode/ResendCode.d.ts +8 -0
  44. package/dist/esm/components/FieldCode/components/ResendCode/ResendCode.js +25 -0
  45. package/dist/esm/components/FieldCode/components/ResendCode/index.d.ts +1 -0
  46. package/dist/esm/components/FieldCode/components/ResendCode/index.js +1 -0
  47. package/dist/esm/components/FieldCode/components/ResendCode/utils.d.ts +1 -0
  48. package/dist/esm/components/FieldCode/components/ResendCode/utils.js +5 -0
  49. package/dist/esm/components/FieldCode/components/index.d.ts +2 -0
  50. package/dist/esm/components/FieldCode/components/index.js +2 -0
  51. package/dist/esm/components/FieldCode/constants.d.ts +14 -0
  52. package/dist/esm/components/FieldCode/constants.js +7 -0
  53. package/dist/esm/components/FieldCode/hooks/index.d.ts +4 -0
  54. package/dist/esm/components/FieldCode/hooks/index.js +4 -0
  55. package/dist/esm/components/FieldCode/hooks/useCodeInput.d.ts +22 -0
  56. package/dist/esm/components/FieldCode/hooks/useCodeInput.js +95 -0
  57. package/dist/esm/components/FieldCode/hooks/useFieldCodeValidate.d.ts +8 -0
  58. package/dist/esm/components/FieldCode/hooks/useFieldCodeValidate.js +21 -0
  59. package/dist/esm/components/FieldCode/hooks/useFieldHelpers.d.ts +13 -0
  60. package/dist/esm/components/FieldCode/hooks/useFieldHelpers.js +31 -0
  61. package/dist/esm/components/FieldCode/hooks/useFocusCell.d.ts +5 -0
  62. package/dist/esm/components/FieldCode/hooks/useFocusCell.js +19 -0
  63. package/dist/esm/components/FieldCode/index.d.ts +2 -0
  64. package/dist/esm/components/FieldCode/index.js +2 -0
  65. package/dist/esm/components/FieldCode/styles.module.css +37 -0
  66. package/dist/esm/components/FieldCode/utils.d.ts +6 -0
  67. package/dist/esm/components/FieldCode/utils.js +13 -0
  68. package/dist/esm/components/index.d.ts +1 -0
  69. package/dist/esm/components/index.js +1 -0
  70. package/dist/tsconfig.cjs.tsbuildinfo +1 -1
  71. package/dist/tsconfig.esm.tsbuildinfo +1 -1
  72. package/package.json +6 -3
  73. package/src/components/FieldCode/FieldCode.tsx +133 -0
  74. package/src/components/FieldCode/components/Cell/Cell.tsx +35 -0
  75. package/src/components/FieldCode/components/Cell/index.ts +1 -0
  76. package/src/components/FieldCode/components/Cell/styles.module.scss +24 -0
  77. package/src/components/FieldCode/components/ResendCode/ResendCode.tsx +33 -0
  78. package/src/components/FieldCode/components/ResendCode/index.ts +1 -0
  79. package/src/components/FieldCode/components/ResendCode/utils.ts +5 -0
  80. package/src/components/FieldCode/components/index.ts +2 -0
  81. package/src/components/FieldCode/constants.ts +20 -0
  82. package/src/components/FieldCode/hooks/index.ts +4 -0
  83. package/src/components/FieldCode/hooks/useCodeInput.ts +147 -0
  84. package/src/components/FieldCode/hooks/useFieldCodeValidate.ts +35 -0
  85. package/src/components/FieldCode/hooks/useFieldHelpers.ts +52 -0
  86. package/src/components/FieldCode/hooks/useFocusCell.ts +29 -0
  87. package/src/components/FieldCode/index.ts +2 -0
  88. package/src/components/FieldCode/styles.module.scss +48 -0
  89. package/src/components/FieldCode/utils.ts +23 -0
  90. package/src/components/index.ts +1 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cloud-ru/uikit-product-fields-predefined",
3
3
  "title": "Fields Predefined",
4
- "version": "3.0.0",
4
+ "version": "3.0.1-preview-d01a0cca.0",
5
5
  "sideEffects": [
6
6
  "*.css",
7
7
  "*.woff",
@@ -37,9 +37,11 @@
37
37
  "@cloud-ru/uikit-product-mobile-fields": "2.1.2",
38
38
  "@cloud-ru/uikit-product-mobile-modal": "2.1.3",
39
39
  "@cloud-ru/uikit-product-utils": "9.1.0",
40
+ "@siberiacancode/reactuse": "0.3.22",
40
41
  "@snack-uikit/attachment": "0.4.40",
41
42
  "@snack-uikit/button": "0.19.16",
42
43
  "@snack-uikit/drop-zone": "0.9.6",
44
+ "@snack-uikit/fields": "0.51.15",
43
45
  "@snack-uikit/icon-predefined": "0.7.3",
44
46
  "@snack-uikit/input-private": "4.8.8",
45
47
  "@snack-uikit/scroll": "0.10.8",
@@ -48,7 +50,8 @@
48
50
  "awesome-phonenumber": "7.5.0",
49
51
  "classnames": "2.5.1",
50
52
  "merge-refs": "1.2.2",
51
- "react-imask": "7.6.1"
53
+ "react-imask": "7.6.1",
54
+ "sass": "1.80.4"
52
55
  },
53
56
  "devDependencies": {
54
57
  "@types/merge-refs": "1.0.0",
@@ -59,5 +62,5 @@
59
62
  "react-hook-form": ">=7.51.0",
60
63
  "yup": ">=0.32.0"
61
64
  },
62
- "gitHead": "77212a170d04f0b142fcfaa5f9a107f9028fd06c"
65
+ "gitHead": "e5c9c7143ce8fc668e576438ce7316be0b931d48"
63
66
  }
@@ -0,0 +1,133 @@
1
+ import cn from 'classnames';
2
+ import { forwardRef, useImperativeHandle } from 'react';
3
+
4
+ import { FieldDecorator, FieldDecoratorProps } from '@snack-uikit/fields';
5
+
6
+ import { Cell, ResendCode, type ResendCodeProps } from './components';
7
+ import { FIELD_CODE_DEFAULT_FOCUS_EFFECTS, type FieldCodeFocusEffect } from './constants';
8
+ import { useCodeInput, UseCodeInputParams, useFieldHelpers, useFocusCell } from './hooks';
9
+ import styles from './styles.module.scss';
10
+ import { getCellValidationState } from './utils';
11
+
12
+ export type FieldCodeRef = {
13
+ /** Перенести фокус на ячейку с индексом `index` */
14
+ moveFocus: (index: number) => void;
15
+ /** Убрать фокус со всех ячеек кода */
16
+ blurFields: () => void;
17
+ /** Сбросить значение кода */
18
+ resetCode: () => void;
19
+ };
20
+
21
+ /** Собственные пропсы `FieldCode` */
22
+ export type FieldCodeOwnProps = {
23
+ /** CSS-класс компонента */
24
+ className?: string;
25
+ /** CSS-класс ячейки кода */
26
+ cellClassName?: string;
27
+ /** Позиции, после которых нужно вставить пробел (индексы символов, после которых будет пробел) */
28
+ spacing?: number[];
29
+ /** Подсветить пустые символы кода */
30
+ showEmptyChars?: boolean;
31
+ /** Компонент отправки нового кода */
32
+ resendCode?: ResendCodeProps;
33
+ /** Сценарии автофокуса; по умолчанию — первая ячейка при монтировании и после сброса (см. `FieldCodeFocusEffect`) */
34
+ focusEffects?: readonly FieldCodeFocusEffect[];
35
+ /** Сообщение при неверном коде, если не передан свой `error` */
36
+ invalidCode?: string;
37
+ /** Растягивать ячейки на всю доступную ширину; иначе фиксированная ширина по `size` */
38
+ stretchCells?: boolean;
39
+ };
40
+
41
+ export type FieldCodeProps = FieldCodeOwnProps &
42
+ Omit<UseCodeInputParams, 'moveFocus'> &
43
+ Pick<FieldDecoratorProps, 'size' | 'disabled' | 'label' | 'error' | 'data-test-id'>;
44
+
45
+ export const FieldCode = forwardRef<FieldCodeRef, FieldCodeProps>(function FieldCode(props, ref) {
46
+ const {
47
+ codeLength,
48
+ className,
49
+ cellClassName,
50
+ value,
51
+ onChange,
52
+ spacing,
53
+ onComplete,
54
+ size,
55
+ disabled,
56
+ label,
57
+ error,
58
+ invalidCode,
59
+ showEmptyChars,
60
+ resendCode,
61
+ focusEffects = FIELD_CODE_DEFAULT_FOCUS_EFFECTS,
62
+ stretchCells = false,
63
+ 'data-test-id': dataTestId,
64
+ } = props;
65
+
66
+ const { inputsRef, moveFocus, blurFields } = useFocusCell(codeLength);
67
+ const { code, cellHandlers, onChangeCode } = useCodeInput({ value, onChange, codeLength, moveFocus, onComplete });
68
+ const { resetCode } = useFieldHelpers({
69
+ onChangeCode,
70
+ focusEffects,
71
+ moveFocus,
72
+ showEmptyChars,
73
+ code,
74
+ codeLength,
75
+ });
76
+
77
+ useImperativeHandle(
78
+ ref,
79
+ () => ({
80
+ moveFocus,
81
+ blurFields,
82
+ resetCode,
83
+ }),
84
+ [moveFocus, blurFields, resetCode],
85
+ );
86
+
87
+ const resolvedError = error ?? invalidCode;
88
+
89
+ const resolvedDecoratorProps = {
90
+ label,
91
+ disabled,
92
+ size,
93
+ error: resolvedError,
94
+ };
95
+
96
+ return (
97
+ <div
98
+ className={cn(styles.fieldCode, className)}
99
+ data-stretch-cells={stretchCells || undefined}
100
+ {...(dataTestId ? { 'data-test-id': dataTestId } : undefined)}
101
+ >
102
+ <FieldDecorator className={styles.fieldDecorator} {...resolvedDecoratorProps}>
103
+ <div className={styles.codeContainer} data-size={size} data-stretch-cells={stretchCells || undefined}>
104
+ {code.map((char, index) => (
105
+ <Cell
106
+ ref={inputRef => {
107
+ if (inputRef) {
108
+ inputsRef.current[index] = inputRef;
109
+ }
110
+ }}
111
+ key={index}
112
+ className={cn(spacing?.includes(index) && styles.cellSpacing, cellClassName)}
113
+ stretchCells={stretchCells}
114
+ size={size}
115
+ value={char}
116
+ disabled={disabled}
117
+ autoComplete={index === 0 ? 'one-time-code' : undefined}
118
+ onKeyDown={e => cellHandlers.onKeyDown(e, index)}
119
+ onPaste={cellHandlers.onPaste}
120
+ onChange={e => cellHandlers.onChange(e, index)}
121
+ validationState={getCellValidationState(char, showEmptyChars, Boolean(resolvedError))}
122
+ />
123
+ ))}
124
+ </div>
125
+ </FieldDecorator>
126
+
127
+ {resendCode ? <ResendCode {...resendCode} size={resendCode.size ?? size} /> : null}
128
+ </div>
129
+ );
130
+ });
131
+
132
+ export type { FieldCodeFocusEffect } from './constants';
133
+ export { FIELD_CODE_DEFAULT_FOCUS_EFFECTS } from './constants';
@@ -0,0 +1,35 @@
1
+ import cn from 'classnames';
2
+ import { forwardRef } from 'react';
3
+
4
+ import { FieldText, FieldTextProps } from '@snack-uikit/fields';
5
+
6
+ import { ZERO_WIDTH_SPACE } from '../../constants';
7
+ import styles from './styles.module.scss';
8
+
9
+ type CellProps = {
10
+ /** CSS-класс ячейки кода */
11
+ className?: string;
12
+ /** Растягивать ячейку на всю доступную ширину */
13
+ stretchCells?: boolean;
14
+ } & Pick<
15
+ FieldTextProps,
16
+ 'size' | 'disabled' | 'value' | 'autoComplete' | 'onKeyDown' | 'onPaste' | 'onChange' | 'validationState'
17
+ >;
18
+
19
+ export const Cell = forwardRef<HTMLInputElement, CellProps>((props, ref) => {
20
+ const { className, size, stretchCells, value, ...fieldCellProps } = props;
21
+
22
+ return (
23
+ <FieldText
24
+ inputMode='numeric'
25
+ ref={ref}
26
+ className={cn(styles.cell, className)}
27
+ data-size={size}
28
+ data-stretch-cells={stretchCells || undefined}
29
+ showClearButton={false}
30
+ value={value === ZERO_WIDTH_SPACE ? '' : value}
31
+ size={size}
32
+ {...fieldCellProps}
33
+ />
34
+ );
35
+ });
@@ -0,0 +1 @@
1
+ export * from './Cell';
@@ -0,0 +1,24 @@
1
+ $cell-widths: (
2
+ 's': 32px,
3
+ 'm': 40px,
4
+ 'l': 48px,
5
+ );
6
+
7
+ .cell {
8
+ flex-shrink: 0;
9
+
10
+ input {
11
+ text-align: center;
12
+ }
13
+
14
+ @each $size, $width in $cell-widths {
15
+ &[data-size='#{$size}']:not([data-stretch-cells]) {
16
+ width: $width;
17
+ }
18
+ }
19
+
20
+ &[data-stretch-cells] {
21
+ flex: 1 1 0;
22
+ min-width: 0;
23
+ }
24
+ }
@@ -0,0 +1,33 @@
1
+ import { UpdateSVG } from '@cloud-ru/uikit-product-icons';
2
+ import { useLocale } from '@cloud-ru/uikit-product-locale';
3
+ import { ButtonFunction, ButtonFunctionProps } from '@snack-uikit/button';
4
+
5
+ import { formatSecondsAsMmSs } from './utils';
6
+
7
+ export type ResendCodeProps = {
8
+ /** Колбек отправки нового кода */
9
+ onResend: () => void;
10
+ /** Количество секунд до следующего отправления кода */
11
+ secondsToNextResend: number;
12
+ } & Pick<ButtonFunctionProps, 'size' | 'disabled'>;
13
+
14
+ export function ResendCode(props: ResendCodeProps) {
15
+ const { onResend, secondsToNextResend, ...buttonProps } = props;
16
+ const { t } = useLocale('FieldsPredefined');
17
+
18
+ return (
19
+ <ButtonFunction
20
+ label={
21
+ secondsToNextResend > 0
22
+ ? t('FieldCode.resendCodeButtons.resendCodeWithVia', {
23
+ timer: formatSecondsAsMmSs(secondsToNextResend),
24
+ })
25
+ : t('FieldCode.resendCodeButtons.resendCode')
26
+ }
27
+ onClick={onResend}
28
+ icon={<UpdateSVG />}
29
+ iconPosition='before'
30
+ {...buttonProps}
31
+ />
32
+ );
33
+ }
@@ -0,0 +1 @@
1
+ export * from './ResendCode';
@@ -0,0 +1,5 @@
1
+ export function formatSecondsAsMmSs(totalSeconds: number): string {
2
+ const minutes = Math.floor(totalSeconds / 60);
3
+ const seconds = totalSeconds % 60;
4
+ return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
5
+ }
@@ -0,0 +1,2 @@
1
+ export * from './Cell';
2
+ export * from './ResendCode';
@@ -0,0 +1,20 @@
1
+ export const ZERO_WIDTH_SPACE = '\u200B';
2
+
3
+ /**
4
+ * Когда дергать `moveFocus` у `FieldCode` (массив `focusEffects`, порядок не важен).
5
+ * @see {@link FIELD_CODE_DEFAULT_FOCUS_EFFECTS}
6
+ */
7
+ export type FieldCodeFocusEffect =
8
+ /** После монтирования — ячейка `0`. */
9
+ | 'firstCellOnMount'
10
+ /** После `ref.resetCode()` — ячейка `0`. */
11
+ | 'firstCellOnReset'
12
+ /** При включении `showEmptyChars` — первая пустая (все заполнены — последняя); не на каждый ввод. */
13
+ | 'firstCellWhenShowEmptyChars';
14
+
15
+ /** Дефолтный набор для `focusEffects`. */
16
+ export const FIELD_CODE_DEFAULT_FOCUS_EFFECTS: readonly FieldCodeFocusEffect[] = [
17
+ 'firstCellOnMount',
18
+ 'firstCellOnReset',
19
+ 'firstCellWhenShowEmptyChars',
20
+ ];
@@ -0,0 +1,4 @@
1
+ export * from './useCodeInput';
2
+ export * from './useFieldCodeValidate';
3
+ export * from './useFocusCell';
4
+ export * from './useFieldHelpers';
@@ -0,0 +1,147 @@
1
+ import { useRefState } from '@siberiacancode/reactuse';
2
+ import { ClipboardEvent, KeyboardEvent, useCallback, useEffect } from 'react';
3
+
4
+ import { useValueControl } from '@snack-uikit/utils';
5
+
6
+ import { ZERO_WIDTH_SPACE } from '../constants';
7
+ import { isNumberChar, isStringCodeLength, isZeroWidthSpace } from '../utils';
8
+
9
+ export type UseCodeInputParams = {
10
+ /** Количество цифр в коде (ожидается целое ≥ 1) */
11
+ codeLength: number;
12
+ /** Значение кода */
13
+ value?: string;
14
+ /** Колбек изменения значения */
15
+ onChange?: (code: string) => void;
16
+ /** Функция фокуса */
17
+ moveFocus: (index: number) => void;
18
+ /** Колбек достижения ввода всех символов кода */
19
+ onComplete?: (code: string) => void;
20
+ };
21
+
22
+ const buildCodeArray = (str: string, codeLength: number) =>
23
+ Array.from({ length: codeLength }, (_, idx) => str[idx] || ZERO_WIDTH_SPACE);
24
+
25
+ export function useCodeInput(params: UseCodeInputParams) {
26
+ const { value: valueProp, onChange: onChangeProp, codeLength, moveFocus, onComplete } = params;
27
+
28
+ const [value = '', onChange] = useValueControl<string>({
29
+ value: valueProp,
30
+ onChange: onChangeProp,
31
+ defaultValue: '',
32
+ });
33
+
34
+ const codeRef = useRefState<string[]>(buildCodeArray(value, codeLength));
35
+
36
+ const updateCodeByIndex = useCallback(
37
+ (index: number, newChar: string) => {
38
+ codeRef.current[index] = newChar;
39
+ onChange?.(codeRef.current.join(''));
40
+ },
41
+ [codeRef, onChange],
42
+ );
43
+
44
+ const updateFullCode = useCallback(
45
+ (newCode: string) => {
46
+ codeRef.current = newCode.split('');
47
+
48
+ onChange?.(newCode);
49
+ moveFocus(codeLength - 1);
50
+ onComplete?.(newCode);
51
+ },
52
+ [codeLength, codeRef, moveFocus, onChange, onComplete],
53
+ );
54
+
55
+ const handleAfterCellUpdate = useCallback(
56
+ (index: number) => {
57
+ const normalizedCode = codeRef.current.join('');
58
+
59
+ const isLastInput = index === codeLength - 1;
60
+ const isAllInputsFilled = isStringCodeLength(normalizedCode, codeLength);
61
+ if (!isLastInput) {
62
+ moveFocus(index + 1);
63
+ } else if (isAllInputsFilled) {
64
+ onComplete?.(normalizedCode);
65
+ }
66
+ },
67
+ [codeLength, codeRef, moveFocus, onComplete],
68
+ );
69
+
70
+ const deleteChar = useCallback(
71
+ (index: number) => {
72
+ if (codeRef.current[index] && !isZeroWidthSpace(codeRef.current[index])) {
73
+ updateCodeByIndex(index, ZERO_WIDTH_SPACE);
74
+ } else if (index > 0) {
75
+ moveFocus(index - 1);
76
+ }
77
+ },
78
+ [codeRef, moveFocus, updateCodeByIndex],
79
+ );
80
+
81
+ const onAutoCompleteInput = useCallback(
82
+ (code: string, index: number) => {
83
+ if (isStringCodeLength(code, codeLength)) {
84
+ updateFullCode(code);
85
+ return;
86
+ }
87
+
88
+ if (!isNumberChar(code)) {
89
+ return;
90
+ }
91
+
92
+ updateCodeByIndex(index, code);
93
+ handleAfterCellUpdate(index);
94
+ },
95
+ [codeLength, handleAfterCellUpdate, updateCodeByIndex, updateFullCode],
96
+ );
97
+
98
+ const onKeyDown = useCallback(
99
+ (e: KeyboardEvent<HTMLInputElement>, index: number) => {
100
+ switch (e.key) {
101
+ case 'ArrowLeft':
102
+ moveFocus(index - 1);
103
+ break;
104
+ case 'ArrowRight':
105
+ moveFocus(index + 1);
106
+ break;
107
+ case 'Backspace':
108
+ deleteChar(index);
109
+ break;
110
+ default:
111
+ if (isNumberChar(e.key)) {
112
+ e.preventDefault();
113
+ updateCodeByIndex(index, e.key);
114
+ handleAfterCellUpdate(index);
115
+ }
116
+ break;
117
+ }
118
+ },
119
+ [deleteChar, handleAfterCellUpdate, moveFocus, updateCodeByIndex],
120
+ );
121
+
122
+ const onPaste = useCallback(
123
+ (e: ClipboardEvent<HTMLInputElement>) => {
124
+ const codeInput = e?.clipboardData.getData('text') ?? '';
125
+ if (!isStringCodeLength(codeInput, codeLength)) {
126
+ return;
127
+ }
128
+
129
+ updateFullCode(codeInput);
130
+ },
131
+ [codeLength, updateFullCode],
132
+ );
133
+
134
+ useEffect(() => {
135
+ codeRef.current = buildCodeArray(value, codeLength);
136
+ }, [codeLength, codeRef, value]);
137
+
138
+ return {
139
+ code: codeRef.current,
140
+ cellHandlers: {
141
+ onKeyDown,
142
+ onPaste,
143
+ onChange: onAutoCompleteInput,
144
+ },
145
+ onChangeCode: onChange,
146
+ };
147
+ }
@@ -0,0 +1,35 @@
1
+ import { useCallback } from 'react';
2
+
3
+ import { useLocale } from '@cloud-ru/uikit-product-locale';
4
+
5
+ import { isNumberChar } from '../utils';
6
+
7
+ export type UseFieldCodeValidateParams = {
8
+ /** Ожидаемая длина кода (цифр) */
9
+ codeLength: number;
10
+ };
11
+
12
+ /**
13
+ * Возвращает функцию валидации значения кода (пусто / неполный код).
14
+ */
15
+ export function useFieldCodeValidate(params: UseFieldCodeValidateParams) {
16
+ const { codeLength } = params;
17
+ const { t } = useLocale('FieldsPredefined');
18
+
19
+ return useCallback(
20
+ (value?: string | number) => {
21
+ const str = value != null ? String(value) : '';
22
+ const digits = str.split('').filter(isNumberChar).join('');
23
+ if (digits.length === 0) {
24
+ return t('FieldCode.required');
25
+ }
26
+
27
+ if (digits.length < codeLength) {
28
+ return t('FieldCode.minLength', { count: codeLength });
29
+ }
30
+
31
+ return undefined;
32
+ },
33
+ [codeLength, t],
34
+ );
35
+ }
@@ -0,0 +1,52 @@
1
+ import { useCallback, useEffect, useMemo } from 'react';
2
+
3
+ import { type FieldCodeFocusEffect } from '../constants';
4
+ import { getFirstEmptyCellIndex } from '../utils';
5
+
6
+ type UseFieldHelpersParams = {
7
+ onChangeCode: (code: string) => void;
8
+ moveFocus: (index: number) => void;
9
+ focusEffects: readonly FieldCodeFocusEffect[];
10
+ showEmptyChars?: boolean;
11
+ code: readonly string[];
12
+ codeLength: number;
13
+ };
14
+
15
+ export function useFieldHelpers(params: UseFieldHelpersParams) {
16
+ const { onChangeCode, moveFocus, focusEffects: focusEffectsProp, showEmptyChars, code, codeLength } = params;
17
+
18
+ const focusEffectsKey = [...focusEffectsProp].sort().join(',');
19
+ const focusEffects = useMemo(
20
+ () => [...focusEffectsProp],
21
+ // eslint-disable-next-line react-hooks/exhaustive-deps
22
+ [focusEffectsKey],
23
+ );
24
+
25
+ const resetCode = useCallback(() => {
26
+ onChangeCode('');
27
+
28
+ if (focusEffects.includes('firstCellOnReset')) {
29
+ moveFocus(0);
30
+ }
31
+ }, [focusEffects, moveFocus, onChangeCode]);
32
+
33
+ useEffect(() => {
34
+ if (focusEffects.includes('firstCellOnMount')) {
35
+ moveFocus(0);
36
+ }
37
+ }, [focusEffects, moveFocus]);
38
+
39
+ useEffect(() => {
40
+ if (!focusEffects.includes('firstCellWhenShowEmptyChars') || !showEmptyChars) {
41
+ return;
42
+ }
43
+
44
+ const emptyIndex = getFirstEmptyCellIndex(code);
45
+ if (emptyIndex >= 0) {
46
+ moveFocus(emptyIndex);
47
+ }
48
+ // eslint-disable-next-line react-hooks/exhaustive-deps
49
+ }, [showEmptyChars, focusEffects, moveFocus, codeLength]);
50
+
51
+ return { resetCode };
52
+ }
@@ -0,0 +1,29 @@
1
+ import { useCallback, useRef } from 'react';
2
+
3
+ export function useFocusCell(codeLength: number) {
4
+ const inputsRef = useRef<HTMLInputElement[]>([]);
5
+
6
+ const focusInput = useCallback(
7
+ (index: number) => {
8
+ inputsRef.current[index]?.focus();
9
+ },
10
+ [inputsRef],
11
+ );
12
+
13
+ const moveFocus = useCallback(
14
+ (newIndex: number) => {
15
+ if (newIndex >= 0 && newIndex < codeLength) {
16
+ focusInput(newIndex);
17
+ }
18
+ },
19
+ [codeLength, focusInput],
20
+ );
21
+
22
+ const blurFields = useCallback(() => {
23
+ inputsRef.current.forEach(input => {
24
+ input?.blur();
25
+ });
26
+ }, [inputsRef]);
27
+
28
+ return { inputsRef, moveFocus, blurFields };
29
+ }
@@ -0,0 +1,2 @@
1
+ export * from './FieldCode';
2
+ export { useFieldCodeValidate, type UseFieldCodeValidateParams } from './hooks/useFieldCodeValidate';
@@ -0,0 +1,48 @@
1
+ @use '@cloud-ru/figma-tokens-web/build/scss/components/styles-tokens-element' as ste;
2
+ @use 'sass:map';
3
+
4
+ .fieldCode {
5
+ display: flex;
6
+ flex-direction: column;
7
+ align-items: center;
8
+ gap: 8px;
9
+
10
+ &[data-stretch-cells] {
11
+ width: 100%;
12
+ }
13
+ }
14
+
15
+ .fieldDecorator {
16
+ width: unset;
17
+ }
18
+
19
+ .codeContainer {
20
+ display: flex;
21
+ justify-content: center;
22
+
23
+ $container-gaps: (
24
+ 's': 8px,
25
+ 'm': 8px,
26
+ 'l': 12px,
27
+ );
28
+
29
+ @each $size, $gap in $container-gaps {
30
+ &[data-size='#{$size}'] {
31
+ gap: $gap;
32
+ }
33
+ }
34
+ }
35
+
36
+ .cellSpacing {
37
+ $spacing-sizes: (
38
+ 's': 8px,
39
+ 'm': 8px,
40
+ 'l': 12px,
41
+ );
42
+
43
+ @each $size, $margin-right in $spacing-sizes {
44
+ &[data-size='#{$size}'] {
45
+ margin-right: $margin-right;
46
+ }
47
+ }
48
+ }
@@ -0,0 +1,23 @@
1
+ import { FieldTextProps } from '@snack-uikit/fields';
2
+
3
+ import { ZERO_WIDTH_SPACE } from './constants';
4
+
5
+ export const isNumberChar = (char: string) => /^\d$/.test(char);
6
+ export const isStringCodeLength = (input: string, codeLength: number) => new RegExp(`^\\d{${codeLength}}$`).test(input);
7
+ export const isZeroWidthSpace = (value: string) => value === ZERO_WIDTH_SPACE;
8
+
9
+ export function getFirstEmptyCellIndex(code: readonly string[]): number {
10
+ return code.findIndex(isZeroWidthSpace);
11
+ }
12
+
13
+ export const getCellValidationState = (
14
+ value: string,
15
+ showEmptyChars?: boolean,
16
+ error?: boolean,
17
+ ): FieldTextProps['validationState'] => {
18
+ if (showEmptyChars) {
19
+ return isZeroWidthSpace(value) ? 'error' : 'default';
20
+ }
21
+
22
+ return error ? 'error' : 'default';
23
+ };
@@ -5,3 +5,4 @@ export * from './AIDisclaimer';
5
5
  export * from './FieldName';
6
6
  export * from './FieldDescription';
7
7
  export * from './FieldMask';
8
+ export * from './FieldCode';