/** * RepeaterField — renders a list of repeating sub-field groups in the content editor. * * Each item is a collapsible card containing the defined sub-fields. * Items can be added, removed, and reordered via drag-and-drop. */ import { Button, Combobox, Input, InputArea, Switch } from "@cloudflare/kumo"; import { DndContext, closestCenter } from "@dnd-kit/core"; import type { DragEndEvent } from "@dnd-kit/core"; import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { Plus, Trash, DotsSixVertical, CaretDown } from "@phosphor-icons/react"; import * as React from "react"; import { fromDatetimeLocalInputValue, toDatetimeLocalInputValue } from "../lib/datetime-local.js"; import { cn } from "../lib/utils.js"; import { CaretNext } from "./ArrowIcons.js"; import { ImageFieldRenderer, type ImageFieldValue } from "./ImageFieldRenderer.js"; interface RepeaterSubFieldDef { slug: string; type: string; label: string; required?: boolean; options?: string[]; } export interface RepeaterFieldProps { label: string; id: string; value: unknown; onChange: (value: unknown[]) => void; required?: boolean; subFields: RepeaterSubFieldDef[]; minItems?: number; maxItems?: number; } type RepeaterItem = Record & { _key: string }; function ensureKeys(items: unknown[]): RepeaterItem[] { return items.map((item, i) => { const obj = (typeof item === "object" && item !== null ? item : {}) as Record; return { ...obj, _key: (obj._key as string) || `item-${i}-${Date.now()}` }; }); } function stripKeys(items: RepeaterItem[]): Record[] { return items.map(({ _key, ...rest }) => rest); } export function RepeaterField({ label, id, value, onChange, subFields, minItems = 0, maxItems, }: RepeaterFieldProps) { const { t } = useLingui(); const rawItems = Array.isArray(value) ? value : []; const [items, setItems] = React.useState(() => ensureKeys(rawItems)); const [collapsedItems, setCollapsedItems] = React.useState>(new Set()); // Sync from external value changes. // Preserve each item's _key by position so round-trips through onChange // (which strips _key) don't remount children on every keystroke. React.useEffect(() => { const incoming = Array.isArray(value) ? value : []; setItems((prev) => incoming.map((item, i) => { const obj = (typeof item === "object" && item !== null ? item : {}) as Record< string, unknown >; const existingKey = (obj._key as string) || prev[i]?._key; return { ...obj, _key: existingKey || `item-${i}-${Date.now()}`, }; }), ); }, [value]); const emitChange = (updated: RepeaterItem[]) => { setItems(updated); onChange(stripKeys(updated)); }; const handleAdd = () => { if (maxItems && items.length >= maxItems) return; const newItem: RepeaterItem = { _key: `item-${Date.now()}` }; for (const sf of subFields) { newItem[sf.slug] = sf.type === "boolean" ? false : sf.type === "number" || sf.type === "integer" || sf.type === "image" ? null : ""; } emitChange([...items, newItem]); }; const handleRemove = (key: string) => { if (items.length <= minItems) return; emitChange(items.filter((item) => item._key !== key)); }; const handleItemChange = (key: string, fieldSlug: string, fieldValue: unknown) => { emitChange( items.map((item) => (item._key === key ? { ...item, [fieldSlug]: fieldValue } : item)), ); }; const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; const oldIndex = items.findIndex((item) => item._key === active.id); const newIndex = items.findIndex((item) => item._key === over.id); if (oldIndex === -1 || newIndex === -1) return; emitChange(arrayMove(items, oldIndex, newIndex)); }; const toggleCollapse = (key: string) => { setCollapsedItems((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }; const canAdd = !maxItems || items.length < maxItems; const canRemove = items.length > minItems; return (
{canAdd && ( )}
{items.length === 0 ? (

{t`No items yet`}

{canAdd && ( )}
) : ( item._key)} strategy={verticalListSortingStrategy} >
{items.map((item, index) => ( toggleCollapse(item._key)} onRemove={canRemove ? () => handleRemove(item._key) : undefined} onChange={(fieldSlug, fieldValue) => handleItemChange(item._key, fieldSlug, fieldValue) } /> ))}
)}
); } interface SortableRepeaterItemProps { item: RepeaterItem; index: number; subFields: RepeaterSubFieldDef[]; isCollapsed: boolean; onToggleCollapse: () => void; onRemove?: () => void; onChange: (fieldSlug: string, value: unknown) => void; } function SortableRepeaterItem({ item, index, subFields, isCollapsed, onToggleCollapse, onRemove, onChange, }: SortableRepeaterItemProps) { const { t } = useLingui(); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item._key, }); const style = { transform: CSS.Transform.toString(transform), transition, }; // Use the first text sub-field as the item summary label const summaryField = subFields.find((sf) => sf.type === "string" || sf.type === "text"); const summaryValue = summaryField ? (item[summaryField.slug] as string) || "" : ""; const summaryLabel = summaryValue || t`Item ${index + 1}`; return (
{/* Header */}
e.stopPropagation()} /> {isCollapsed ? ( ) : ( )} {summaryLabel} {onRemove && ( )}
{/* Sub-fields */} {!isCollapsed && (
{subFields.map((sf) => ( onChange(sf.slug, v)} /> ))}
)}
); } interface SubFieldInputProps { subField: RepeaterSubFieldDef; value: unknown; onChange: (value: unknown) => void; } function SubFieldInput({ subField, value, onChange }: SubFieldInputProps) { const { t } = useLingui(); switch (subField.type) { case "string": return ( onChange(e.target.value)} required={subField.required} dir="auto" /> ); case "text": return ( onChange(e.target.value)} required={subField.required} rows={3} dir="auto" /> ); case "number": case "integer": return ( onChange(e.target.value ? Number(e.target.value) : null)} required={subField.required} step={subField.type === "integer" ? "1" : "any"} /> ); case "boolean": return ( onChange(checked)} label={{subField.label}} /> ); case "datetime": return ( onChange(fromDatetimeLocalInputValue(e.target.value))} required={subField.required} /> ); case "select": { // Searchable combobox so long option lists (e.g. taxonomy-derived // options) stay usable inside repeater rows, rather than a plain // scrolling select. const options = Array.isArray(subField.options) ? subField.options : []; return ( onChange(typeof v === "string" ? v : "")} items={options} required={subField.required} > {t`No results`} {(opt: string) => ( {opt} )} ); } case "image": return ( onChange(v)} required={subField.required} /> ); default: return ( onChange(e.target.value)} /> ); } }