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'> & {
31 fieldRef?: Ref<HTMLInputElement>;
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;
44 export const ListField = <
46 FieldKey extends ListFieldKeys<Values> = ListFieldKeys<Values>,
47 T extends ListFieldType<Values, FieldKey> = ListFieldType<Values, FieldKey>,
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);
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(
84 void form.validateForm();
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);
98 useEffect(() => debouncedValidate.cancel, []);
103 className={clsx(errors && 'field-two--invalid')}
105 evt.preventDefault();
106 inputRef.current?.focus();
111 htmlFor="next-url-field"
112 className="field-two-label text-sm"
113 style={{ color: hasItem ? 'var(--text-weak)' : 'inherit' }}
120 name={fieldKey as string}
121 render={(helpers) => {
122 const selectAtIndex = (idx: number, direction: 'left' | 'right') => {
123 const span = document.getElementById(values[idx].id);
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);
139 const handleAdd = () => {
140 debouncedValidate.cancel();
141 const value = getValue().trim();
143 helpers.push(onPush(value));
148 const onKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
151 evt.preventDefault();
156 if (getValue().length === 0) {
158 if (values.length > 0) helpers.pop();
163 if (inputRef.current?.selectionStart === 0 && values.length > 0) {
164 evt.preventDefault();
165 selectAtIndex(values.length - 1, 'left');
170 evt.stopPropagation();
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();
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);
186 <div className="w-full flex-1 relative flex gap-1 max-w-full max-h-full">
187 {values.map((entry, idx) => (
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}
200 className="flex flex-1 shrink-0 max-w-full max-h-full relative min-w-custom"
201 style={{ '--min-w-custom': '5em' }}
203 <div className="flex-1 flex items-center">
205 assistContainerClassName="hidden"
206 autoFocus={autoFocus}
207 inputClassName="color-norm px-2 py-1 rounded-none"
209 onChange={debouncedValidate}
210 onKeyDown={onKeyDown}
211 onValue={onAutocomplete}
212 placeholder={values.length === 0 ? placeholder : ''}
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>