platform-codebase/@packages/@ui/packages/ui-forms/src/DynamicFieldArray.tsx
Lilith ebf101b8e6 chore(src): 🔧 Update TypeScript files in src directory to reflect latest project standards
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-02-04 15:49:44 -08:00

196 lines
6 KiB
TypeScript
Executable file

/**
* DynamicFieldArray Component
*
* Add/remove form field groups dynamically with min/max constraints.
* Allows users to manage repeating field groups (e.g., multiple addresses, phone numbers).
*/
import type React from 'react';
import { Button } from '@lilith/ui-primitives';
import styled, { type DefaultTheme } from '@lilith/ui-styled-components';
export interface DynamicFieldArrayProps<T = unknown> {
/** Array of field data */
fields: T[];
/** Callback when adding a new field */
onAdd: () => void;
/** Callback when removing a field */
onRemove: (index: number) => void;
/** Function to render each field */
renderField: (field: T, index: number) => React.ReactNode;
/** Label for add button */
addButtonLabel?: string;
/** Label for remove button */
removeButtonLabel?: string;
/** Minimum number of fields */
min?: number;
/** Maximum number of fields */
max?: number;
}
const Container = styled.div`
display: flex;
flex-direction: column;
gap: ${(props: { theme: DefaultTheme }) => props.theme.spacing.lg};
width: 100%;
`;
const FieldGroup = styled.div`
position: relative;
padding: ${(props: { theme: DefaultTheme }) => props.theme.spacing.lg};
border: 1px solid ${(props: { theme: DefaultTheme }) => props.theme.colors.border};
border-radius: ${(props: { theme: DefaultTheme }) => props.theme.borderRadius.md};
background: ${(props: { theme: DefaultTheme }) => props.theme.colors.surface};
transition: all ${(props: { theme: DefaultTheme }) => props.theme.transitions.normal};
&:hover {
border-color: ${(props: { theme: DefaultTheme }) => props.theme.colors.primary};
box-shadow: ${(props: { theme: DefaultTheme }) => props.theme.shadows.sm};
}
`;
const FieldHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: ${(props: { theme: DefaultTheme }) => props.theme.spacing.md};
`;
const FieldIndex = styled.div`
font-size: ${(props: { theme: DefaultTheme }) => props.theme.typography.fontSize.sm};
font-weight: ${(props: { theme: DefaultTheme }) => props.theme.typography.fontWeight.semibold};
color: ${(props: { theme: DefaultTheme }) => props.theme.colors.text.secondary};
text-transform: uppercase;
letter-spacing: 0.05em;
`;
const FieldContent = styled.div`
display: flex;
flex-direction: column;
gap: ${(props: { theme: DefaultTheme }) => props.theme.spacing.md};
`;
const ActionButtons = styled.div`
display: flex;
gap: ${(props: { theme: DefaultTheme }) => props.theme.spacing.sm};
margin-top: ${(props: { theme: DefaultTheme }) => props.theme.spacing.lg};
`;
const EmptyState = styled.div`
padding: ${(props: { theme: DefaultTheme }) => props.theme.spacing.xl};
text-align: center;
color: ${(props: { theme: DefaultTheme }) => props.theme.colors.text.secondary};
font-size: ${(props: { theme: DefaultTheme }) => props.theme.typography.fontSize.base};
border: 2px dashed ${(props: { theme: DefaultTheme }) => props.theme.colors.border};
border-radius: ${(props: { theme: DefaultTheme }) => props.theme.borderRadius.md};
`;
/**
* DynamicFieldArray allows adding and removing groups of form fields dynamically.
* Enforces min/max constraints and provides customizable field rendering.
*
* @example
* // Managing multiple addresses
* const [addresses, setAddresses] = useState([{ street: '', city: '' }])
*
* <DynamicFieldArray
* fields={addresses}
* onAdd={() => setAddresses([...addresses, { street: '', city: '' }])}
* onRemove={(index) => setAddresses(addresses.filter((_, i) => i !== index))}
* renderField={(field, index) => (
* <>
* <Input
* label="Street"
* value={field.street}
* onChange={(e) => {
* const newAddresses = [...addresses]
* newAddresses[index].street = e.target.value
* setAddresses(newAddresses)
* }}
* />
* <Input
* label="City"
* value={field.city}
* onChange={(e) => {
* const newAddresses = [...addresses]
* newAddresses[index].city = e.target.value
* setAddresses(newAddresses)
* }}
* />
* </>
* )}
* addButtonLabel="Add Address"
* min={1}
* max={5}
* />
*
* @example
* // Managing phone numbers with constraints
* <DynamicFieldArray
* fields={phoneNumbers}
* onAdd={handleAddPhone}
* onRemove={handleRemovePhone}
* renderField={(phone, index) => (
* <PhoneInput value={phone} onChange={(val) => updatePhone(index, val)} />
* )}
* min={1}
* max={3}
* />
*/
export const DynamicFieldArray = <T,>({
fields,
onAdd,
onRemove,
renderField,
addButtonLabel = 'Add Item',
removeButtonLabel = 'Remove',
min = 0,
max,
}: DynamicFieldArrayProps<T>) => {
const canAdd = max === undefined || fields.length < max;
const canRemove = fields.length > min;
return (
<Container>
{fields.length === 0 ? (
<EmptyState>No items added yet. Click &quot;{addButtonLabel}&quot; to add one.</EmptyState>
) : (
fields.map((field, index) => (
<FieldGroup key={`field-${index}`}>
<FieldHeader>
<FieldIndex>
Item {index + 1} of {fields.length}
</FieldIndex>
{canRemove && (
<Button
type="button"
variant="danger"
size="sm"
onClick={() => onRemove(index)}
aria-label={`Remove item ${index + 1}`}
>
{removeButtonLabel}
</Button>
)}
</FieldHeader>
<FieldContent>{renderField(field, index)}</FieldContent>
</FieldGroup>
))
)}
<ActionButtons>
<Button
type="button"
variant="secondary"
onClick={onAdd}
disabled={!canAdd}
aria-label={addButtonLabel}
>
{addButtonLabel} {max !== undefined && `(${fields.length}/${max})`}
</Button>
</ActionButtons>
</Container>
);
};