import React, { FunctionComponent, Children, useRef, useCallback, useState, useEffect } from 'react'; import { Button, ButtonProps, FormGroup, type FormGroupProps, Flex, FlexItem } from '@patternfly/react-core'; import { Table, Tbody, Td, Th, Tr, Thead } from '@patternfly/react-table'; import { PlusCircleIcon, MinusCircleIcon } from '@patternfly/react-icons'; /** * Defines the helpers passed to the children render prop. * This provides accessibility labels and focus management for each row. */ export interface FieldRowHelpers { /** * Ref callback to attach to the first focusable element in the row. * This enables automatic focus management when rows are added/removed. */ focusRef: (element: HTMLElement | null) => void; /** * Unique ID for this row group */ rowGroupId: string; /** * Complete aria-label string for the first column that includes both row and column context */ firstColumnAriaLabel: string; /** * Complete aria-label string for the second column that includes both row and column context */ secondColumnAriaLabel?: string; } /** * Extends FormGroupProps to inherit standard functionality like * label, helperText, isRequired, and validation states. */ export interface FieldBuilderProps extends Omit { /** Provides an accessible name for the field builder table via a human readable string. */ 'aria-label'?: string; /** Provides an accessible name for the field builder table via a space separated list of IDs. */ 'aria-labelledby'?: string; /** Label for the first column */ firstColumnLabel: React.ReactNode; /** Label for the second column in a two-column layout */ secondColumnLabel?: React.ReactNode; /** The total number of rows to render. This should be derived from the length of the state array managed by the parent. */ rowCount: number; /** * A function that returns the content for each row. This "render prop" provides * maximum flexibility for defining the inputs within each row. * Can return 1 child (single-column) or 2 children (two-column). * The second parameter provides the 0-based index of the current row. */ children: (helpers: FieldRowHelpers, index: number) => React.ReactNode; /** A callback triggered when the "Add" button is clicked. */ onAddRow: (event: React.MouseEvent) => void; /** A callback triggered when a "Remove" button is clicked, which receives the index of the row to remove. */ onRemoveRow: (event: React.MouseEvent, index: number) => void; /** Additional props to customize the "Add" button. */ addButtonProps?: Omit; /** Content for the "Add" button. Defaults to "Add another". */ addButtonContent?: React.ReactNode; /** Additional props to customize the "Remove" buttons. */ removeButtonProps?: Omit; /** * Callback to customize the aria-label for remove buttons. * If not provided, defaults to "Remove {rowGroupLabelPrefix} {rowNumber}". */ removeButtonAriaLabel?: (rowNumber: number, rowGroupLabelPrefix: string) => string; /** * Label prefix for each row group. Defaults to "Row". This is also used to create a default * Table accessible name when the aria-label nor aria-labelledby are not provided. */ rowGroupLabelPrefix?: string; /** * Unique ID prefix for this FieldBuilder instance. * This ensures unique IDs when multiple FieldBuilders exist on the same page. */ fieldBuilderIdPrefix?: string; /** * Callback to customize the announcement message when a row is added. * If not provided, defaults to "New {rowGroupLabelPrefix} added. {rowGroupLabelPrefix} {newRowNumber}." */ onAddRowAnnouncement?: (rowNumber: number, rowGroupLabelPrefix: string) => string; /** * Callback to customize the announcement message when a row is removed. * If not provided, defaults to "{rowGroupLabelPrefix} {removedRowNumber} removed." */ onRemoveRowAnnouncement?: (rowNumber: number, rowGroupLabelPrefix: string) => string; } /** * FieldBuilder is a component group that simplifies the creation of dynamic, * multi-row forms with a consistent layout. It manages the layout and actions * for adding and removing rows, while giving the consumer full control over the fields themselves. */ export const FieldBuilder: FunctionComponent = ({ 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, firstColumnLabel, secondColumnLabel, rowCount, children, onAddRow, onRemoveRow, addButtonProps = {}, addButtonContent, removeButtonProps = {}, removeButtonAriaLabel, rowGroupLabelPrefix = 'Row', fieldBuilderIdPrefix = 'field-builder', onAddRowAnnouncement, onRemoveRowAnnouncement, ...formGroupProps }: FieldBuilderProps) => { // Track focusable elements for each row (for consumers who want to use focusRef) const focusableElementsRef = useRef>(new Map()); // State for ARIA live region announcements const [ liveRegionMessage, setLiveRegionMessage ] = useState(''); // Track previous row count for focus management const previousRowCountRef = useRef(rowCount); // Track the last removed row index for focus management const lastRemovedIndexRef = useRef(null); // Reference to the add button for focus management const addButtonRef = useRef(null); // Function to announce changes to screen readers const announceChange = useCallback((message: string) => { setLiveRegionMessage(message); // Clear the message after a delay to prepare for next announcement setTimeout(() => { setLiveRegionMessage(''); }, 1000); }, []); // Focus management effect - runs when rowCount changes useEffect(() => { const previousRowCount = previousRowCountRef.current; if (rowCount > previousRowCount) { // Row was added - focus the first input of the new row // Use setTimeout to ensure DOM is fully rendered for complex components like Select setTimeout(() => { const newRowIndex = rowCount - 1; const newRowFirstElement = focusableElementsRef.current.get(newRowIndex); if (newRowFirstElement) { newRowFirstElement.focus(); } }, 0); } else if (rowCount < previousRowCount && lastRemovedIndexRef.current !== null) { // Row was removed - apply smart focus logic // Use setTimeout to ensure DOM is fully updated after row removal setTimeout(() => { const removedIndex = lastRemovedIndexRef.current!; if (rowCount === 0) { // No rows left - focus the add button if (addButtonRef.current) { addButtonRef.current.focus(); } } else if (removedIndex >= rowCount) { // Removed the last row - focus the new last row's first element const newLastRowIndex = rowCount - 1; const newLastRowFirstElement = focusableElementsRef.current.get(newLastRowIndex); if (newLastRowFirstElement) { newLastRowFirstElement.focus(); } } else { // Removed a middle row - focus the first element of the row that took its place const sameIndexFirstElement = focusableElementsRef.current.get(removedIndex); if (sameIndexFirstElement) { sameIndexFirstElement.focus(); } } // Reset the removed index tracker lastRemovedIndexRef.current = null; }, 0); } // Update the previous row count previousRowCountRef.current = rowCount; }, [ rowCount ]); // Create ref callback for focusable elements const createFocusRef = useCallback( (rowIndex: number) => (element: HTMLElement | null) => { if (element) { focusableElementsRef.current.set(rowIndex, element); } else { focusableElementsRef.current.delete(rowIndex); } }, [] ); // Enhanced onAddRow with focus management and announcements const handleAddRow = useCallback( (event: React.MouseEvent) => { onAddRow(event); const newRowNumber = rowCount + 1; const announcementMessage = onAddRowAnnouncement ? onAddRowAnnouncement(newRowNumber, rowGroupLabelPrefix) : `New ${rowGroupLabelPrefix.toLowerCase()} added. ${rowGroupLabelPrefix} ${newRowNumber}.`; announceChange(announcementMessage); }, [ onAddRow, announceChange, rowGroupLabelPrefix, rowCount, onAddRowAnnouncement ] ); // Enhanced onRemoveRow with announcements and focus tracking const handleRemoveRow = useCallback( (event: React.MouseEvent, index: number) => { const rowNumber = index + 1; // Track which row is being removed for focus management lastRemovedIndexRef.current = index; onRemoveRow(event, index); // Announce the removal const announcementMessage = onRemoveRowAnnouncement ? onRemoveRowAnnouncement(rowNumber, rowGroupLabelPrefix) : `${rowGroupLabelPrefix} ${rowNumber} removed.`; announceChange(announcementMessage); }, [ onRemoveRow, announceChange, rowGroupLabelPrefix, onRemoveRowAnnouncement ] ); // Helper function to render all the dynamic rows. const renderRows = () => { const rows = Array.from({ length: rowCount }); return rows.map((_, index) => { const rowNumber = index + 1; const rowGroupId = `${fieldBuilderIdPrefix}-row-${index}`; const topPaddingClass = index > 0 ? 'pf-v6-u-pt-0' : ''; // Call the user's render prop function to get the React nodes for this row's cells. const rowContent = children( { focusRef: createFocusRef(index), rowGroupId, firstColumnAriaLabel: `${rowGroupLabelPrefix} ${rowNumber}, ${firstColumnLabel}`, secondColumnAriaLabel: secondColumnLabel ? `${rowGroupLabelPrefix} ${rowNumber}, ${secondColumnLabel}` : undefined }, index ); // Safely convert the returned content into an array of children. const cells = Children.toArray(rowContent); // Validate that 1 or 2 children are provided if (cells.length < 1 || cells.length > 2) { // Only render the first 2 children to prevent layout issues cells.splice(2); // Ensure at least 1 child exists if (cells.length < 1) { cells.push(
); } } const preDeleteCellStyles = { paddingInlineEnd: 'var(--pf-t--global--spacer--xs)' }; return ( {/* First column cell */} {cells[0]} {/* Second column cell (if two-column layout) */} {secondColumnLabel && ( {cells[1] ||
} )} {/* Remove button column */} ); }; export default FieldBuilder;