@cloud-ru/uikit-product-fields-predefined 0.13.3

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 (65) hide show
  1. package/CHANGELOG.md +935 -0
  2. package/LICENSE +201 -0
  3. package/README.md +151 -0
  4. package/package.json +63 -0
  5. package/src/components/AIDisclaimer/AIDisclaimer.tsx +18 -0
  6. package/src/components/AIDisclaimer/index.ts +1 -0
  7. package/src/components/AIDisclaimer/styles.module.scss +19 -0
  8. package/src/components/FieldAi/FieldAi.tsx +145 -0
  9. package/src/components/FieldAi/components/CheckItem/CheckItem.tsx +44 -0
  10. package/src/components/FieldAi/components/CheckItem/index.ts +1 -0
  11. package/src/components/FieldAi/components/CheckItem/styles.module.scss +28 -0
  12. package/src/components/FieldAi/components/MobileFieldAi/MobileFieldAi.tsx +57 -0
  13. package/src/components/FieldAi/components/MobileFieldAi/index.ts +1 -0
  14. package/src/components/FieldAi/components/MobileFieldAi/styles.module.scss +81 -0
  15. package/src/components/FieldAi/components/PasswordValidation/PasswordValidation.tsx +78 -0
  16. package/src/components/FieldAi/components/PasswordValidation/index.ts +1 -0
  17. package/src/components/FieldAi/components/PasswordValidation/styles.module.scss +32 -0
  18. package/src/components/FieldAi/components/TextArea/TextArea.tsx +113 -0
  19. package/src/components/FieldAi/components/TextArea/index.ts +1 -0
  20. package/src/components/FieldAi/components/TextArea/styles.module.scss +35 -0
  21. package/src/components/FieldAi/components/WithPasswordValidation/WithPasswordValidation.tsx +51 -0
  22. package/src/components/FieldAi/components/WithPasswordValidation/index.ts +1 -0
  23. package/src/components/FieldAi/components/WithPasswordValidation/styles.module.scss +7 -0
  24. package/src/components/FieldAi/index.ts +1 -0
  25. package/src/components/FieldAi/styles.module.scss +33 -0
  26. package/src/components/FieldAi/utils.ts +25 -0
  27. package/src/components/FieldChat/FieldChat.tsx +101 -0
  28. package/src/components/FieldChat/components/Attachments/Attachments.tsx +32 -0
  29. package/src/components/FieldChat/components/Attachments/index.ts +1 -0
  30. package/src/components/FieldChat/components/Attachments/styles.module.scss +9 -0
  31. package/src/components/FieldChat/index.ts +1 -0
  32. package/src/components/FieldChat/styles.module.scss +13 -0
  33. package/src/components/FieldPhone/FieldPhone.tsx +226 -0
  34. package/src/components/FieldPhone/__tests__/constants.ts +26 -0
  35. package/src/components/FieldPhone/__tests__/formatPhoneNumber.spec.ts +15 -0
  36. package/src/components/FieldPhone/__tests__/matchMedia.ts +15 -0
  37. package/src/components/FieldPhone/constants.ts +1 -0
  38. package/src/components/FieldPhone/countries.tsx +1755 -0
  39. package/src/components/FieldPhone/hooks/index.ts +2 -0
  40. package/src/components/FieldPhone/hooks/useCountries.ts +40 -0
  41. package/src/components/FieldPhone/hooks/useMapCountryToOptions.ts +25 -0
  42. package/src/components/FieldPhone/index.ts +7 -0
  43. package/src/components/FieldPhone/styles.module.scss +9 -0
  44. package/src/components/FieldPhone/types.ts +32 -0
  45. package/src/components/FieldPhone/utils.ts +71 -0
  46. package/src/components/SelectCreate/SelectCreate.tsx +122 -0
  47. package/src/components/SelectCreate/SelectFooter/SelectFooter.tsx +29 -0
  48. package/src/components/SelectCreate/SelectFooter/index.ts +1 -0
  49. package/src/components/SelectCreate/SelectFooter/styles.module.scss +9 -0
  50. package/src/components/SelectCreate/index.ts +1 -0
  51. package/src/components/SelectCreate/types.ts +35 -0
  52. package/src/components/SelectCreate/useSelectDataStates.tsx +53 -0
  53. package/src/components/index.ts +5 -0
  54. package/src/helperComponents/FieldSubmitButton/FieldSubmitButton.tsx +42 -0
  55. package/src/helperComponents/FieldSubmitButton/index.ts +1 -0
  56. package/src/helperComponents/TextAreaActionsFooter/TextAreaActionsFooter.tsx +18 -0
  57. package/src/helperComponents/TextAreaActionsFooter/index.ts +1 -0
  58. package/src/helperComponents/TextAreaActionsFooter/styles.module.scss +23 -0
  59. package/src/helpers/capitalize.ts +3 -0
  60. package/src/helpers/getSymbolsRangeFromMask.ts +17 -0
  61. package/src/helpers/index.ts +3 -0
  62. package/src/helpers/isTouchDevice.ts +5 -0
  63. package/src/hooks/index.ts +1 -0
  64. package/src/hooks/useOpen.ts +22 -0
  65. package/src/index.ts +3 -0
@@ -0,0 +1,32 @@
1
+ import { Attachment, AttachmentSquare, AttachmentSquareProps } from '@snack-uikit/attachment';
2
+ import { Scroll } from '@snack-uikit/scroll';
3
+
4
+ import styles from './styles.module.scss';
5
+
6
+ export type AttachmentsProps = {
7
+ files: AttachmentSquareProps[];
8
+ isMobile?: boolean;
9
+ };
10
+
11
+ export function Attachments({ files, isMobile }: AttachmentsProps) {
12
+ if (!files.length) {
13
+ return null;
14
+ }
15
+
16
+ const AttachmentComponent = isMobile ? Attachment : AttachmentSquare;
17
+
18
+ return (
19
+ <Scroll>
20
+ <div className={styles.attachments}>
21
+ {files.map((file, index) => (
22
+ <AttachmentComponent
23
+ key={file.file?.name || index}
24
+ size='s'
25
+ {...file}
26
+ className={isMobile ? styles.attachment : undefined}
27
+ />
28
+ ))}
29
+ </div>
30
+ </Scroll>
31
+ );
32
+ }
@@ -0,0 +1 @@
1
+ export * from './Attachments';
@@ -0,0 +1,9 @@
1
+ .attachments {
2
+ display: flex;
3
+ gap: 8px;
4
+ padding: 1px 0;
5
+ }
6
+
7
+ .attachment {
8
+ min-width: 256px;
9
+ }
@@ -0,0 +1 @@
1
+ export * from './FieldChat';
@@ -0,0 +1,13 @@
1
+ .fieldChat {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: 11px;
5
+
6
+ &[data-layout-type='mobile'] {
7
+ gap: 7px;
8
+ }
9
+ }
10
+
11
+ .uploadTooltip {
12
+ display: flex;
13
+ }
@@ -0,0 +1,226 @@
1
+ import cn from 'classnames';
2
+ import mergeRefs from 'merge-refs';
3
+ import { ClipboardEventHandler, forwardRef, useEffect, useMemo, useRef, useState } from 'react';
4
+ import { useIMask } from 'react-imask';
5
+
6
+ import { AdaptiveDroplist } from '@cloud-ru/uikit-product-mobile-dropdown';
7
+ import { AdaptiveFieldText, FieldTextProps } from '@cloud-ru/uikit-product-mobile-fields';
8
+ import { WithLayoutType } from '@cloud-ru/uikit-product-utils';
9
+ import { useValueControl } from '@snack-uikit/utils';
10
+
11
+ import { PLACEHOLDER_CHAR } from './constants';
12
+ import { useCountries } from './hooks';
13
+ import styles from './styles.module.scss';
14
+ import { CountrySettings, FieldPhoneOptionsProps, MaskOptions } from './types';
15
+ import { detectCountryByPhone } from './utils';
16
+
17
+ export type FieldPhoneProps = WithLayoutType<
18
+ Omit<
19
+ FieldTextProps,
20
+ | 'prefix'
21
+ | 'prefixIcon'
22
+ | 'postfix'
23
+ | 'placeholder'
24
+ | 'autocomplete'
25
+ | 'decoratorRef'
26
+ | 'allowMoreThanMaxLength'
27
+ | 'onKeyDown'
28
+ | 'button'
29
+ | 'maxLength'
30
+ | 'inputMode'
31
+ > & {
32
+ /** Включить скролл для основной части списка стран */
33
+ scrollList?: boolean;
34
+ onChange?(value: string): void;
35
+ searchPlaceholder?: string;
36
+ onChangeCountry?(country: FieldPhoneOptionsProps): void;
37
+ } & {
38
+ /** options — объект конфигурации для изменения стандартного списка стран */
39
+ options?: CountrySettings;
40
+ }
41
+ >;
42
+
43
+ export const FieldPhone = forwardRef<HTMLInputElement, FieldPhoneProps>(
44
+ (
45
+ {
46
+ value: valueProp,
47
+ onChangeCountry,
48
+ onChange: onChangeProp,
49
+ showClearButton = true,
50
+ searchPlaceholder,
51
+ onPaste,
52
+ className,
53
+ scrollList,
54
+ options: optionsProp,
55
+ ...rest
56
+ },
57
+ ref,
58
+ ) => {
59
+ const [open, setOpen] = useState(false);
60
+
61
+ const localRef = useRef<HTMLInputElement>(null);
62
+
63
+ const options = useCountries(optionsProp);
64
+ const isOnlyOneCountryAvailable = options.length === 1;
65
+
66
+ const [country, setCountry] = useValueControl<FieldPhoneOptionsProps>({
67
+ defaultValue: options[0],
68
+ onChange: onChangeCountry,
69
+ });
70
+
71
+ const [dropdownSearch, setDropDownSearch] = useState('');
72
+
73
+ const items = useMemo(() => {
74
+ if (dropdownSearch.length) {
75
+ return options.filter(opt =>
76
+ [opt.content.option, opt.content.caption].some(val =>
77
+ String(val).toLowerCase().includes(dropdownSearch.toLowerCase()),
78
+ ),
79
+ );
80
+ }
81
+
82
+ return options;
83
+ }, [options, dropdownSearch]);
84
+
85
+ const maskOptions = useMemo<MaskOptions>(
86
+ () => ({
87
+ mask: country?.mask,
88
+ lazy: false,
89
+ placeholderChar: PLACEHOLDER_CHAR,
90
+ definitions: {
91
+ X: /[0-9]/,
92
+ },
93
+ }),
94
+ [country?.mask],
95
+ );
96
+
97
+ const {
98
+ ref: iMaskRef,
99
+ value: iMaskValue,
100
+ setValue,
101
+ unmaskedValue,
102
+ } = useIMask<HTMLInputElement>(maskOptions, {
103
+ onAccept: (_: string, maskRef) => {
104
+ const unmasked = maskRef.unmaskedValue;
105
+
106
+ const requiredSymbols = country?.mask.replace(/[\D]/g, '');
107
+
108
+ const value = unmasked.length ? `${country?.content.caption}${requiredSymbols}${unmasked}` : '';
109
+
110
+ if (value !== valueProp) {
111
+ onChangeProp?.(value);
112
+ }
113
+ },
114
+ });
115
+
116
+ useEffect(() => {
117
+ const requiredSymbols = country?.mask.replace(/[\D]/g, '');
118
+ const normalizedValue = valueProp?.replace((country?.content.caption ?? '') + requiredSymbols, '');
119
+
120
+ if (normalizedValue !== undefined && normalizedValue !== unmaskedValue) {
121
+ setValue(normalizedValue);
122
+ }
123
+ // need to trigger update only on valueProp change
124
+ // eslint-disable-next-line react-hooks/exhaustive-deps
125
+ }, [valueProp]);
126
+
127
+ const updateMaskView = (value?: string) => {
128
+ setValue(value?.replaceAll(/\w/g, ' ') ?? '');
129
+ };
130
+
131
+ const handlePaste: ClipboardEventHandler<HTMLInputElement> = e => {
132
+ e.preventDefault();
133
+
134
+ const text = e.clipboardData?.getData('text') || '';
135
+
136
+ const newCountry = detectCountryByPhone(text, options);
137
+ const isCountryChanged = newCountry && newCountry.id !== country?.id;
138
+
139
+ const currentCountry = isCountryChanged ? newCountry : country;
140
+
141
+ const prefixNumber = (currentCountry?.content.caption ?? '').replace('+', '');
142
+ const prefixNumberWithOptionalPlus = RegExp(`^(\\+?${prefixNumber})`);
143
+ const valueWithoutPrefix = text.replace(prefixNumberWithOptionalPlus, '');
144
+
145
+ // костыль, чтобы всегда срабатывала маска
146
+ const newValue = `+${valueWithoutPrefix}`;
147
+ if (isCountryChanged) {
148
+ setCountry(newCountry);
149
+ updateMaskView(newCountry.mask);
150
+
151
+ setTimeout(() => setValue(newValue), 0);
152
+ } else {
153
+ setValue(newValue);
154
+ }
155
+
156
+ onPaste?.(e);
157
+ };
158
+
159
+ const handleChangeSelection = (selectedOption: string) => {
160
+ if (selectedOption && selectedOption !== country?.content.option) {
161
+ const selectedCountry = options.find(opt => opt.id === selectedOption);
162
+
163
+ updateMaskView(selectedCountry?.mask);
164
+ setCountry(selectedCountry);
165
+ }
166
+
167
+ setTimeout(() => localRef.current?.focus(), 500);
168
+ };
169
+
170
+ const handleChange = (value: string) => {
171
+ // needed only to clear value by clicking on clear button
172
+ if (unmaskedValue && !value) {
173
+ updateMaskView(country?.mask);
174
+ localRef.current?.focus();
175
+ }
176
+ };
177
+
178
+ const showClear = showClearButton && Boolean(unmaskedValue);
179
+
180
+ return (
181
+ <AdaptiveFieldText
182
+ {...rest}
183
+ inputMode='tel'
184
+ data-test-id='field-phone'
185
+ type='tel'
186
+ ref={mergeRefs(ref, localRef, iMaskRef)}
187
+ className={cn(className, styles.fieldPhone)}
188
+ data-empty={!unmaskedValue || undefined}
189
+ value={iMaskValue}
190
+ onChange={handleChange}
191
+ onPaste={handlePaste}
192
+ showClearButton={showClear}
193
+ prefix={country?.content.caption}
194
+ button={
195
+ isOnlyOneCountryAvailable
196
+ ? undefined
197
+ : {
198
+ variant: 'before',
199
+ hasArrow: true,
200
+ arrowOpen: open,
201
+ wrapper: button => (
202
+ <div role='presentation' onClick={e => e.stopPropagation()}>
203
+ <AdaptiveDroplist
204
+ onOpenChange={setOpen}
205
+ closeDroplistOnItemClick
206
+ layoutType={rest.layoutType}
207
+ items={items}
208
+ selection={{ mode: 'single', onChange: handleChangeSelection, value: country?.id }}
209
+ scroll={scrollList}
210
+ search={{
211
+ value: dropdownSearch,
212
+ onChange: setDropDownSearch,
213
+ placeholder: searchPlaceholder,
214
+ }}
215
+ >
216
+ {button}
217
+ </AdaptiveDroplist>
218
+ </div>
219
+ ),
220
+ content: country?.beforeContent,
221
+ }
222
+ }
223
+ />
224
+ );
225
+ },
226
+ );
@@ -0,0 +1,26 @@
1
+ export const phoneFormatCases = [
2
+ { country: 'Russia', input: '+79878887879', expected: '+7 987 888-78-79' },
3
+ { country: 'Russia', input: '+7987****875', expected: '+7 987 ***-*8-75' },
4
+ { country: 'Belarus', input: '+375129876543', expected: '+375 12 987-65-43' },
5
+ { country: 'Kazakhstan', input: '+79878887879', expected: '+7 987 888-78-79' },
6
+ { country: 'Armenia', input: '+37412987654', expected: '+374 12 987-654' },
7
+ { country: 'Kyrgyzstan', input: '+996987888777', expected: '+996 987 888-777' },
8
+ { country: 'Georgia', input: '+995987888777', expected: '+995 987 88-87-77' },
9
+ { country: 'Brazil', input: '+5512345678901', expected: '+55 12 34567-8901' },
10
+ { country: 'India', input: '+911234567890', expected: '+91 12345-67890' },
11
+ { country: 'UnitedArabEmirates', input: '+971123456789', expected: '+971 12 345-6789' },
12
+ { country: 'SaudiArabia', input: '+966123456789', expected: '+966 1 2345-6789' },
13
+ { country: 'SouthAfrica', input: '+27123456789', expected: '+27 12 345-6789' },
14
+ { country: 'Egypt', input: '+201234567890', expected: '+20 12 3456-7890' },
15
+ { country: 'Iran', input: '+981234567890', expected: '+98 123 456-7890' },
16
+ { country: 'Ethiopia', input: '+251123456789', expected: '+251 12 345-6789' },
17
+ { country: 'Romania', input: '+40123456789', expected: '+40 12 345-6789' },
18
+ { country: 'Serbia', input: '+381123456789', expected: '+381 12 345-6789' },
19
+ { country: 'Netherlands', input: '+31123456789', expected: '+31 12 345-6789' },
20
+ { country: 'Azerbaijan', input: '+994123456789', expected: '+994 12 345-67-89' },
21
+ { country: 'Syria', input: '+963123456789', expected: '+963 12 345-6789' },
22
+ { country: 'Cyprus', input: '+35712345678', expected: '+357 12 345678' },
23
+ { country: 'Uzbekistan', input: '+998123456789', expected: '+998 12 345-67-89' },
24
+ { country: 'Tajikistan', input: '+992123456789', expected: '+992 12 345-6789' },
25
+ { country: 'Moldova', input: '+37312345678', expected: '+373 1234 5678' },
26
+ ];
@@ -0,0 +1,15 @@
1
+ import './matchMedia';
2
+
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import { ALL_COUNTRY_CODES } from '../countries';
6
+ import { formatPhoneNumber } from '../utils';
7
+ import { phoneFormatCases } from './constants';
8
+
9
+ describe('Checking the mask overlay for each number', () => {
10
+ phoneFormatCases.forEach(({ country, input, expected }) => {
11
+ it(`should format the number correctly for ${country}`, () => {
12
+ expect(formatPhoneNumber(input, ALL_COUNTRY_CODES)).toBe(expected);
13
+ });
14
+ });
15
+ });
@@ -0,0 +1,15 @@
1
+ import { vi } from 'vitest';
2
+
3
+ Object.defineProperty(window, 'matchMedia', {
4
+ writable: true,
5
+ value: vi.fn().mockImplementation(query => ({
6
+ matches: false,
7
+ media: query,
8
+ onchange: null,
9
+ addListener: vi.fn(),
10
+ removeListener: vi.fn(),
11
+ addEventListener: vi.fn(),
12
+ removeEventListener: vi.fn(),
13
+ dispatchEvent: vi.fn(),
14
+ })),
15
+ });
@@ -0,0 +1 @@
1
+ export const PLACEHOLDER_CHAR = '_';