Merge branch 'feat/inda-383-daily-stat' into 'main'
[ProtonMail-WebClient.git] / packages / pass / components / Form / Field / ListField.tsx
blob2c89c7cbdfc76e5809be9ee42f9b3fc64f458ef3
1 /* eslint-disable jsx-a11y/no-static-element-interactions */
2 import type { KeyboardEventHandler, ReactNode, Ref } from 'react';
3 import { useCallback, useEffect, useRef } from 'react';
5 import type { FieldProps, FormikErrors } from 'formik';
6 import { FieldArray } from 'formik';
8 import type { Input } from '@proton/atoms';
9 import { Icon, InputFieldTwo } from '@proton/components';
10 import type { IconName } from '@proton/components/';
11 import { type InputFieldProps } from '@proton/components/components/v2/field/InputField';
12 import useCombinedRefs from '@proton/hooks/useCombinedRefs';
13 import type { Unpack } from '@proton/pass/types';
14 import { truthy } from '@proton/pass/utils/fp/predicates';
15 import { isEmptyString } from '@proton/pass/utils/string/is-empty-string';
16 import clsx from '@proton/utils/clsx';
17 import debounce from '@proton/utils/debounce';
19 import { FieldBox } from './Layout/FieldBox';
20 import { ListFieldItem } from './ListFieldItem';
22 import './ListField.scss';
24 export type ListFieldValue<T> = { id: string; value: T };
25 export type ListFieldType<V, K extends keyof V> = Unpack<V[K]> extends ListFieldValue<infer U> ? U : never;
26 export type ListFieldKeys<V> = { [K in keyof V]: V[K] extends ListFieldValue<any>[] ? K : never }[keyof V];
28 type ListFieldProps<Values, FieldKey extends ListFieldKeys<Values>, T = ListFieldType<Values, FieldKey>> = FieldProps &
29     Omit<InputFieldProps<typeof Input>, 'onValue' | 'onBlur'> & {
30         fieldKey: FieldKey;
31         fieldRef?: Ref<HTMLInputElement>;
32         icon?: IconName;
33         label?: string;
34         placeholder?: string;
35         fieldValue: (entry: T) => string;
36         onAutocomplete?: (value: string) => void;
37         onBlur?: (value: string) => void;
38         onPush: (value: string) => ListFieldValue<T>;
39         onReplace: (value: string, entry: ListFieldValue<T>) => ListFieldValue<T>;
40         renderError?: (errors: FormikErrors<ListFieldValue<T>[]>) => ReactNode;
41         fieldLoading?: (entry: ListFieldValue<T>) => boolean;
42     };
44 export const ListField = <
45     Values,
46     FieldKey extends ListFieldKeys<Values> = ListFieldKeys<Values>,
47     T extends ListFieldType<Values, FieldKey> = ListFieldType<Values, FieldKey>,
48 >({
49     autoFocus,
50     fieldKey,
51     fieldRef,
52     form,
53     icon,
54     label,
55     placeholder,
56     fieldValue,
57     onAutocomplete,
58     onBlur,
59     onPush,
60     onReplace,
61     renderError,
62     fieldLoading,
63 }: ListFieldProps<Values, FieldKey>) => {
64     const inputRef = useRef<HTMLInputElement>(null);
65     const ref = useCombinedRefs(fieldRef, inputRef);
67     /* Move the trailing input outside of react life-cycle  */
68     const getValue = useCallback(() => inputRef.current!.value, []);
69     const setValue = useCallback((value: string) => {
70         inputRef.current!.value = value;
71         onAutocomplete?.(value);
72     }, []);
74     const values = (form.values[fieldKey] ?? []) as ListFieldValue<T>[];
75     const errors = (form.errors[fieldKey] ?? []) as FormikErrors<ListFieldValue<T>>[];
76     const hasItem = Boolean(values) || values.some(({ value }) => !isEmptyString(fieldValue(value)));
78     /** For onBlur and onChange events, validation is triggered after a small timeout to
79      * prevent flickering error validation. This timeout accommodates scenarios where the
80      * user clicks on a possible autocomplete suggestion, potentially triggering the onBlur
81      * event, or when the user is actively making changes in the input field. */
82     const debouncedValidate = useCallback(
83         debounce(() => {
84             void form.validateForm();
85         }, 250),
86         []
87     );
89     const handleBlur = () => {
90         /* Avoid immediate validation when marking the field as touched
91          * on blur to prevent error warning for potentially invalid trailing
92          * input values before the `onBlur` call. */
93         void form.setFieldTouched(fieldKey as string, true, false);
94         onBlur?.(getValue());
95         debouncedValidate();
96     };
98     useEffect(() => debouncedValidate.cancel, []);
100     return (
101         <FieldBox
102             icon={icon}
103             className={clsx(errors && 'field-two--invalid')}
104             onClick={(evt) => {
105                 evt.preventDefault();
106                 inputRef.current?.focus();
107             }}
108         >
109             {label && (
110                 <label
111                     htmlFor="next-url-field"
112                     className="field-two-label text-sm"
113                     style={{ color: hasItem ? 'var(--text-weak)' : 'inherit' }}
114                 >
115                     {label}
116                 </label>
117             )}
119             <FieldArray
120                 name={fieldKey as string}
121                 render={(helpers) => {
122                     const selectAtIndex = (idx: number, direction: 'left' | 'right') => {
123                         const span = document.getElementById(values[idx].id);
124                         if (!span) return;
125                         span.focus();
126                         if (direction === 'left') {
127                             const range = document.createRange();
128                             const textNode = span.firstChild;
129                             const selection = getSelection();
131                             range.setStart(textNode!, span.innerText.length);
132                             range.collapse(true);
134                             selection?.removeAllRanges();
135                             selection?.addRange(range);
136                         }
137                     };
139                     const handleAdd = () => {
140                         debouncedValidate.cancel();
141                         const value = getValue().trim();
142                         if (value) {
143                             helpers.push(onPush(value));
144                             setValue('');
145                         }
146                     };
148                     const onKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
149                         switch (evt.key) {
150                             case 'Enter':
151                                 evt.preventDefault();
152                                 handleAdd();
153                                 break;
155                             case 'Backspace':
156                                 if (getValue().length === 0) {
157                                     setValue('');
158                                     if (values.length > 0) helpers.pop();
159                                 }
160                                 break;
162                             case 'ArrowLeft':
163                                 if (inputRef.current?.selectionStart === 0 && values.length > 0) {
164                                     evt.preventDefault();
165                                     selectAtIndex(values.length - 1, 'left');
166                                 }
167                                 break;
169                             case ' ':
170                                 evt.stopPropagation();
171                         }
172                     };
174                     const onMoveLeft = (idx: number) => () => idx > 0 && selectAtIndex(idx - 1, 'left');
176                     const onMoveRight = (idx: number) => () => {
177                         if (idx < values.length - 1) selectAtIndex(idx + 1, 'right');
178                         else inputRef.current?.focus();
179                     };
181                     const onChange = (idx: number, entry: ListFieldValue<T>) => (value: string) => {
182                         if (value.trim()) helpers.replace<ListFieldValue<unknown>>(idx, onReplace(value, entry));
183                         else helpers.remove(idx);
184                     };
185                     return (
186                         <div className="w-full flex-1 relative flex gap-1 max-w-full max-h-full">
187                             {values.map((entry, idx) => (
188                                 <ListFieldItem<T>
189                                     {...entry}
190                                     key={entry.id}
191                                     error={Boolean(errors?.[idx])}
192                                     loading={fieldLoading ? fieldLoading(entry) : false}
193                                     onChange={onChange(idx, entry)}
194                                     onMoveLeft={onMoveLeft(idx)}
195                                     onMoveRight={onMoveRight(idx)}
196                                     renderValue={fieldValue}
197                                 />
198                             ))}
199                             <div
200                                 className="flex flex-1 shrink-0 max-w-full max-h-full relative min-w-custom"
201                                 style={{ '--min-w-custom': '5em' }}
202                             >
203                                 <div className="flex-1 flex items-center">
204                                     <InputFieldTwo
205                                         assistContainerClassName="hidden"
206                                         autoFocus={autoFocus}
207                                         inputClassName="color-norm px-2 py-1 rounded-none"
208                                         onBlur={handleBlur}
209                                         onChange={debouncedValidate}
210                                         onKeyDown={onKeyDown}
211                                         onValue={onAutocomplete}
212                                         placeholder={values.length === 0 ? placeholder : ''}
213                                         ref={ref}
214                                         unstyled
215                                     />
216                                 </div>
217                             </div>
218                         </div>
219                     );
220                 }}
221             />
223             {renderError && errors.filter(truthy).length > 0 && form.dirty && (
224                 <div className="field-two-assist flex flex-nowrap items-start mt-4">
225                     <Icon name="exclamation-circle-filled" className="shrink-0 mr-1" />
226                     <span>{renderError(errors)}</span>
227                 </div>
228             )}
229         </FieldBox>
230     );