mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-02 08:07:40 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
259
apps/web-react/app/form/fields/ArrayFormField.tsx
Normal file
259
apps/web-react/app/form/fields/ArrayFormField.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { ChevronDown, ChevronUp, 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<FormFieldProps<"array">, "field"> & {
|
||||
name: string;
|
||||
value: unknown[];
|
||||
onChange: (value: unknown[]) => void;
|
||||
renderItem: (index: number, name: string) => React.ReactNode;
|
||||
isObjectArray?: boolean;
|
||||
sortable?: boolean;
|
||||
itemInitialValue?: unknown;
|
||||
addable?: boolean;
|
||||
canRemoveItem?: (itemValue: unknown, index: number) => boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function ArrayFormField({
|
||||
label,
|
||||
name,
|
||||
bottomText,
|
||||
error,
|
||||
min = 0,
|
||||
max,
|
||||
value,
|
||||
onChange,
|
||||
renderItem,
|
||||
isObjectArray,
|
||||
sortable,
|
||||
itemInitialValue,
|
||||
addable = true,
|
||||
canRemoveItem,
|
||||
disabled,
|
||||
}: 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<string, unknown>),
|
||||
_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<string, unknown>, ["_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) =>
|
||||
!disabled &&
|
||||
(canRemoveItem ? canRemoveItem(value[index], index) : true) &&
|
||||
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<string, unknown>)?._key as string) ?? idx;
|
||||
};
|
||||
|
||||
// Sorting is only offered for object arrays; primitive arrays are rendered
|
||||
// inline without the fieldset header that carries the reorder controls.
|
||||
const isSortable = Boolean(sortable) && isObjectArray && !disabled;
|
||||
|
||||
const handleMoveAt = (index: number, direction: 1 | -1) => {
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= count) return;
|
||||
|
||||
const next = [...value];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack md w-full">
|
||||
{translatedLabel ? (
|
||||
<div className="text-xs font-semi-bold">{translatedLabel}</div>
|
||||
) : null}
|
||||
{isObjectArray
|
||||
? Array.from({ length: visibleCount }).map((_, idx) => (
|
||||
<ArrayItemFieldset
|
||||
key={itemKey(idx)}
|
||||
index={idx}
|
||||
canRemove={canRemoveAt(idx)}
|
||||
removeButtonTestId={`${name}-remove-item-button`}
|
||||
onRemove={() => handleRemoveAt(idx)}
|
||||
sortable={isSortable}
|
||||
canMoveUp={idx > 0}
|
||||
canMoveDown={idx < count - 1}
|
||||
onMoveUp={() => handleMoveAt(idx, -1)}
|
||||
onMoveDown={() => handleMoveAt(idx, 1)}
|
||||
>
|
||||
{renderItem(idx, `${name}[${idx}]`)}
|
||||
</ArrayItemFieldset>
|
||||
))
|
||||
: Array.from({ length: visibleCount }).map((_, idx) => (
|
||||
<div
|
||||
key={itemKey(idx)}
|
||||
className="stack horizontal sm items-start w-full"
|
||||
>
|
||||
<div className={styles.itemInput}>
|
||||
{renderItem(idx, `${name}[${idx}]`)}
|
||||
</div>
|
||||
{canRemoveAt(idx) ? (
|
||||
<SendouButton
|
||||
icon={<Trash />}
|
||||
aria-label="Remove item"
|
||||
size="small"
|
||||
variant="minimal-destructive"
|
||||
onPress={() => handleRemoveAt(idx)}
|
||||
className={styles.removeButton}
|
||||
data-testid={`${name}-remove-item-button`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{translatedError ? (
|
||||
<FormMessage type="error">{translatedError}</FormMessage>
|
||||
) : null}
|
||||
{translatedBottomText && !translatedError ? (
|
||||
<FormMessage type="info">{translatedBottomText}</FormMessage>
|
||||
) : null}
|
||||
{addable ? (
|
||||
<SendouButton
|
||||
size="small"
|
||||
variant="outlined"
|
||||
icon={<Plus />}
|
||||
onPress={handleAdd}
|
||||
isDisabled={count >= max || disabled}
|
||||
className="m-0-auto"
|
||||
data-testid={`${name}-add-item-button`}
|
||||
>
|
||||
{t("common:actions.add")}
|
||||
</SendouButton>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArrayItemFieldset({
|
||||
index,
|
||||
children,
|
||||
canRemove,
|
||||
removeButtonTestId,
|
||||
onRemove,
|
||||
sortable,
|
||||
canMoveUp,
|
||||
canMoveDown,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
}: {
|
||||
index: number;
|
||||
children: React.ReactNode;
|
||||
canRemove: boolean;
|
||||
removeButtonTestId?: string;
|
||||
onRemove: () => void;
|
||||
sortable?: boolean;
|
||||
canMoveUp?: boolean;
|
||||
canMoveDown?: boolean;
|
||||
onMoveUp?: () => void;
|
||||
onMoveDown?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<fieldset className={styles.card}>
|
||||
<div className={styles.header}>
|
||||
<legend className={styles.headerLabel}>#{index + 1}</legend>
|
||||
{sortable ? (
|
||||
<>
|
||||
<SendouButton
|
||||
shape="circle"
|
||||
icon={<ChevronDown />}
|
||||
aria-label="Move down"
|
||||
size="small"
|
||||
variant="minimal"
|
||||
onPress={onMoveDown}
|
||||
isDisabled={!canMoveDown}
|
||||
/>
|
||||
<SendouButton
|
||||
shape="circle"
|
||||
icon={<ChevronUp />}
|
||||
aria-label="Move up"
|
||||
size="small"
|
||||
variant="minimal"
|
||||
onPress={onMoveUp}
|
||||
isDisabled={!canMoveUp}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<SendouButton
|
||||
className={canRemove ? undefined : "invisible"}
|
||||
shape="circle"
|
||||
icon={<Trash />}
|
||||
aria-label="Remove item"
|
||||
size="small"
|
||||
variant="minimal-destructive"
|
||||
onPress={onRemove}
|
||||
isDisabled={!canRemove}
|
||||
data-testid={removeButtonTestId}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.content}>{children}</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user