import { Plus, Trash } from "lucide-react"; import type * as React from "react"; import { useTranslation } from "react-i18next"; import { isDeepEqual, omit } from "remeda"; import { SendouButton } from "~/components/elements/Button"; import { FormMessage } from "~/components/FormMessage"; import type { FormFieldProps } from "../types"; import styles from "./ArrayFormField.module.css"; import { useTranslatedTexts } from "./FormFieldWrapper"; type ArrayFormFieldProps = Omit, "field"> & { name: string; value: unknown[]; onChange: (value: unknown[]) => void; renderItem: (index: number, name: string) => React.ReactNode; isObjectArray?: boolean; sortable?: boolean; itemInitialValue?: unknown; }; export function ArrayFormField({ label, name, bottomText, error, min = 0, max, value, onChange, renderItem, isObjectArray, sortable, itemInitialValue, }: ArrayFormFieldProps) { const { t } = useTranslation(["common"]); const { translatedLabel, translatedBottomText, translatedError } = useTranslatedTexts({ label, bottomText, error }); const count = value.length; // Always render at least one item so an empty array still shows an input // the user can fill, rather than only an "Add" button. The underlying value // stays empty until edited, so submitting an untouched field sends nothing. const minVisible = Math.max(min, 1); const visibleCount = Math.max(count, minVisible); const makeNewItem = () => { const baseValue = itemInitialValue !== undefined ? itemInitialValue : isObjectArray ? {} : undefined; return typeof baseValue === "object" && baseValue !== null ? { ...(baseValue as Record), _key: crypto.randomUUID(), } : baseValue; }; const handleAdd = () => { // While the array is empty we still render one placeholder row that isn't // part of `value` yet. Pad `value` up to the number of visible rows first so // the added item appears below them instead of only backing the placeholder. const padded = [...value]; while (padded.length < visibleCount) { padded.push(makeNewItem()); } onChange([...padded, makeNewItem()]); }; // An item the user hasn't touched still equals the freshly added template, so // it's indistinguishable from the placeholder shown for an empty array. const isPristineItem = (item: unknown) => { const template = itemInitialValue; if (typeof template === "object" && template !== null) { if (typeof item !== "object" || item === null) return true; return isDeepEqual( omit(item as Record, ["_key"]), template, ); } return template === undefined ? item === null || item === undefined || item === "" : isDeepEqual(item, template); }; // A single pristine row is indistinguishable from the empty-array placeholder, // so it shouldn't offer a remove button (you can't go below one visible row // anyway). A lone edited row stays removable so the only item can be cleared. const canRemoveAt = (index: number) => count > min && (count > minVisible || !isPristineItem(value[index])); const handleRemoveAt = (index: number) => { const next = value.filter((_, i) => i !== index); // Removing down to a single pristine row would leave a stray entry that // looks untouched but still fails validation on submit; collapse it back to // an empty array so it matches the pristine state. onChange(next.length === 1 && isPristineItem(next[0]) ? [] : next); }; const itemKey = (idx: number) => { if (!isObjectArray) return idx; return ((value[idx] as Record)?._key as string) ?? idx; }; return (
{translatedLabel ? (
{translatedLabel}
) : null} {Array.from({ length: visibleCount }).map((_, idx) => isObjectArray ? ( handleRemoveAt(idx)} sortable={sortable} > {renderItem(idx, `${name}[${idx}]`)} ) : (
{renderItem(idx, `${name}[${idx}]`)}
{canRemoveAt(idx) ? ( } aria-label="Remove item" size="small" variant="minimal-destructive" onPress={() => handleRemoveAt(idx)} className={styles.removeButton} /> ) : null}
), )} {translatedError ? ( {translatedError} ) : null} {translatedBottomText && !translatedError ? ( {translatedBottomText} ) : null} } onPress={handleAdd} isDisabled={count >= max} className="m-0-auto" > {t("common:actions.add")}
); } function ArrayItemFieldset({ index, children, canRemove, onRemove, sortable, }: { index: number; children: React.ReactNode; canRemove: boolean; onRemove: () => void; sortable?: boolean; }) { return (
{sortable ? : null} #{index + 1} } aria-label="Remove item" size="small" variant="minimal-destructive" onPress={onRemove} isDisabled={!canRemove} />
{children}
); }