From 663c60e61d3def6c78ba173a9018684dc32c81a4 Mon Sep 17 00:00:00 2001 From: Parthiv Krishnan Date: Mon, 28 Jul 2025 20:48:25 -0400 Subject: [PATCH 1/8] feat(component): Field builder component using grid layout --- .../examples/FieldBuilder/FieldBuilder.md | 30 ++ .../FieldBuilder/FieldBuilderExample.tsx | 76 ++++ .../FieldBuilderSelectExample.tsx | 122 +++++++ .../module/src/FieldBuilder/FieldBuilder.tsx | 330 ++++++++++++++++++ packages/module/src/FieldBuilder/index.ts | 2 + 5 files changed, 560 insertions(+) create mode 100644 packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilder.md create mode 100644 packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderExample.tsx create mode 100644 packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderSelectExample.tsx create mode 100644 packages/module/src/FieldBuilder/FieldBuilder.tsx create mode 100644 packages/module/src/FieldBuilder/index.ts diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilder.md b/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilder.md new file mode 100644 index 00000000..ce1bb0df --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilder.md @@ -0,0 +1,30 @@ +--- +section: Component groups +subsection: Helpers +id: Field Builder +source: react +propComponents: ['FieldBuilder'] +--- + +import { FunctionComponent, useState } from 'react'; +import { FieldBuilder } from '@patternfly/react-component-groups/dist/dynamic/FieldBuilder'; +import { MinusCircleIcon } from '@patternfly/react-icons'; + + +## Examples + +### Basic Field Builder + +This is a basic field builder! + +```js file="./FieldBuilderExample.tsx" + +``` + +### Field Builder Select + +This is a field builder with Select components! + +```js file="./FieldBuilderSelectExample.tsx" + +``` \ No newline at end of file diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderExample.tsx new file mode 100644 index 00000000..c1ae5567 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderExample.tsx @@ -0,0 +1,76 @@ +import React, { useState } from 'react'; +import { + Form, + TextInput, +} from '@patternfly/react-core'; +import { FieldBuilder } from '@patternfly/react-component-groups/dist/dynamic/FieldBuilder'; + +interface Contact { + name: string; + email: string; +} + +export const FieldBuilderExample: React.FunctionComponent = () => { + const [ contacts, setContacts ] = useState([ + { name: '', email: '' } + ]); + + // Handle adding a new contact row + const handleAddContact = () => { + setContacts([ ...contacts, { name: '', email: '' } ]); + }; + + // Handle removing a contact row + const handleRemoveContact = (index: number) => { + setContacts(contacts.filter((_, i) => i !== index)); + }; + + // Handle updating contact data + const handleContactChange = (index: number, field: keyof Contact, value: string) => { + const updatedContacts = [ ...contacts ]; + updatedContacts[index] = { ...updatedContacts[index], [field]: value }; + setContacts(updatedContacts); + }; + + return ( +
+ + {({ rowIndex, focusRef, firstColumnAriaLabelledBy, secondColumnAriaLabelledBy }) => [ + handleContactChange(rowIndex, 'name', value)} + aria-labelledby={firstColumnAriaLabelledBy} + isRequired + />, + handleContactChange(rowIndex, 'email', value)} + aria-labelledby={secondColumnAriaLabelledBy} + isRequired + /> + ]} + +
+ ); +}; + +export default FieldBuilderExample; diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderSelectExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderSelectExample.tsx new file mode 100644 index 00000000..7dc900c3 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderSelectExample.tsx @@ -0,0 +1,122 @@ +import React, { useState } from 'react'; +import { + Form, + FormSelect, + FormSelectOption, +} from '@patternfly/react-core'; +import { FieldBuilder } from '@patternfly/react-component-groups/dist/dynamic/FieldBuilder'; + +interface TeamMember { + department: string; + role: string; +} + +export const FieldBuilderSelectExample: React.FunctionComponent = () => { + const [ teamMembers, setTeamMembers ] = useState([ + { department: '', role: '' } + ]); + + // Handle adding a new team member row + const handleAddTeamMember = () => { + setTeamMembers([ ...teamMembers, { department: '', role: '' } ]); + }; + + // Handle removing a team member row + const handleRemoveTeamMember = (index: number) => { + setTeamMembers(teamMembers.filter((_, i) => i !== index)); + }; + + // Handle updating team member data + const handleTeamMemberChange = (index: number, field: keyof TeamMember, value: string) => { + const updatedTeamMembers = [ ...teamMembers ]; + updatedTeamMembers[index] = { ...updatedTeamMembers[index], [field]: value }; + setTeamMembers(updatedTeamMembers); + }; + + // Create a ref callback that works with FormSelect + const createFormSelectRef = (focusRef: (element: HTMLElement | null) => void) => + (instance: React.ComponentRef | HTMLElement | null) => { + if (instance) { + // Get the underlying DOM element from the FormSelect instance + const domElement = (instance as any)?.ref?.current || instance; + if (domElement instanceof HTMLElement) { + focusRef(domElement); + } + } + }; + + const departmentOptions = [ + { label: 'Choose a department', value: '', disabled: true }, + { label: 'Engineering', value: 'engineering' }, + { label: 'Marketing', value: 'marketing' }, + { label: 'Sales', value: 'sales' }, + { label: 'Human Resources', value: 'hr' }, + { label: 'Finance', value: 'finance' } + ]; + + const roleOptions = [ + { label: 'Choose a role', value: '', disabled: true }, + { label: 'Manager', value: 'manager' }, + { label: 'Senior', value: 'senior' }, + { label: 'Junior', value: 'junior' }, + { label: 'Intern', value: 'intern' }, + { label: 'Contractor', value: 'contractor' } + ]; + + return ( +
+ + {({ rowIndex, focusRef, firstColumnAriaLabelledBy, secondColumnAriaLabelledBy }) => [ + handleTeamMemberChange(rowIndex, 'department', value)} + aria-labelledby={firstColumnAriaLabelledBy} + isRequired + > + {departmentOptions.map((option, optionIndex) => ( + + ))} + , + handleTeamMemberChange(rowIndex, 'role', value)} + aria-labelledby={secondColumnAriaLabelledBy} + isRequired + > + {roleOptions.map((option, optionIndex) => ( + + ))} + + ]} + +
+ ); +}; + +export default FieldBuilderSelectExample; diff --git a/packages/module/src/FieldBuilder/FieldBuilder.tsx b/packages/module/src/FieldBuilder/FieldBuilder.tsx new file mode 100644 index 00000000..526f7b84 --- /dev/null +++ b/packages/module/src/FieldBuilder/FieldBuilder.tsx @@ -0,0 +1,330 @@ +import React, { FunctionComponent, Children, useRef, useEffect, useCallback } from 'react'; +import { + Button, + ButtonProps, + FormGroup, + type FormGroupProps, + Flex, + FlexItem, + Grid, + GridItem, +} from '@patternfly/react-core'; +import { PlusCircleIcon, MinusCircleIcon } from '@patternfly/react-icons'; + +/** + * Defines the helpers passed to the children render prop. + * This allows each rendered row to have access to its own specific index. + */ +export interface FieldRowHelpers { + rowIndex: number; + /** + * 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 - use this for aria-labelledby associations + */ + rowGroupId: string; + /** + * ID for the first column label - use this to associate the first field with its column header + */ + firstColumnLabelId: string; + /** + * ID for the second column label - use this to associate the second field with its column header + */ + secondColumnLabelId?: string; + /** + * ID for the row label - use this in combination with column labels for comprehensive labeling + */ + rowLabelId: string; + /** + * Complete aria-labelledby string for the first column that includes both row and column context + */ + firstColumnAriaLabelledBy: string; + /** + * Complete aria-labelledby string for the second column that includes both row and column context + */ + secondColumnAriaLabelledBy?: string; +} + +/** + * Extends FormGroupProps to inherit standard functionality like + * label, helperText, isRequired, and validation states. + */ +export interface FieldBuilderProps extends Omit { + /** Label for the first column (required for both single and two-column layouts) */ + firstColumnLabel: React.ReactNode; + /** Label for the second column (optional, only used in 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). + */ + children: (helpers: FieldRowHelpers) => React.ReactNode; + /** A callback triggered when the "Add" button is clicked. */ + onAddRow: () => void; + /** A callback triggered when a "Remove" button is clicked, which receives the index of the row to remove. */ + onRemoveRow: (index: number) => void; + /** Optional props to customize the "Add" button. */ + addButtonProps?: Omit; + /** + * Optional label prefix for each row group. Defaults to "Row". + * Screen readers will announce this as "Row 1", "Row 2", etc. + */ + rowGroupLabelPrefix?: string; + /** + * Optional unique ID prefix for this FieldBuilder instance. + * This ensures unique IDs when multiple FieldBuilders exist on the same page. + */ + fieldBuilderIdPrefix?: 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 = ({ + firstColumnLabel, + secondColumnLabel, + rowCount, + children, + onAddRow, + onRemoveRow, + addButtonProps = {}, + rowGroupLabelPrefix = "Row", + fieldBuilderIdPrefix = "field-builder", + ...formGroupProps +}: FieldBuilderProps) => { + // Track the previous row count to detect when rows are added + const prevRowCountRef = useRef(rowCount); + // Track focusable elements for each row + const focusableElementsRef = useRef>(new Map()); + // Track remove button refs for focus management + const removeButtonRefs = useRef>(new Map()); + // Track the add button ref + const addButtonRef = useRef(null); + // ARIA live region for announcing dynamic changes + const liveRegionRef = useRef(null); + + // Generate unique IDs for this instance + const instanceId = useRef(`${fieldBuilderIdPrefix}-${Math.random().toString(36).substr(2, 9)}`); + const firstColumnLabelId = `${instanceId.current}-first-column-label`; + const secondColumnLabelId = secondColumnLabel ? `${instanceId.current}-second-column-label` : undefined; + + // Function to announce changes to screen readers + const announceChange = useCallback((message: string) => { + if (liveRegionRef.current) { + liveRegionRef.current.textContent = message; + // Clear the message after a delay to prepare for next announcement + setTimeout(() => { + if (liveRegionRef.current) { + liveRegionRef.current.textContent = ''; + } + }, 1000); + } + }, []); + + // 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); + } + }, []); + + // Create ref callback for remove buttons + const createRemoveButtonRef = useCallback((rowIndex: number) => + (element: HTMLButtonElement | null) => { + if (element) { + removeButtonRefs.current.set(rowIndex, element); + } else { + removeButtonRefs.current.delete(rowIndex); + } + }, []); + + // Enhanced onAddRow with focus management and announcements + const handleAddRow = useCallback(() => { + onAddRow(); + announceChange(`New ${rowGroupLabelPrefix.toLowerCase()} added. ${rowGroupLabelPrefix} ${rowCount + 1}.`); + }, [ onAddRow, announceChange, rowGroupLabelPrefix, rowCount ]); + + // Enhanced onRemoveRow with focus management and announcements + const handleRemoveRow = useCallback((index: number) => { + const rowNumber = index + 1; + + // Determine where focus should go after removal + const focusTarget = () => { + // If removing the last row and there are other rows, focus the new last row's remove button + if (index === rowCount - 1 && rowCount > 1) { + return removeButtonRefs.current.get(index - 1); + } + // If removing a middle row, focus the remove button that will take its place (same index) + else if (index < rowCount - 1) { + // Give React time to re-render, then focus the remove button at the same index + setTimeout(() => { + const newButton = removeButtonRefs.current.get(index); + if (newButton) { + newButton.focus(); + } + }, 0); + return null; // Return null to skip immediate focus + } + // If removing the only row, focus the add button + else { + return addButtonRef.current; + } + }; + + const elementToFocus = focusTarget(); + onRemoveRow(index); + + // Announce the removal + announceChange(`${rowGroupLabelPrefix} ${rowNumber} removed.`); + + // Focus immediately if we have a target (for last row or only row cases) + if (elementToFocus) { + setTimeout(() => { + elementToFocus.focus(); + }, 0); + } + }, [ onRemoveRow, rowCount, announceChange, rowGroupLabelPrefix ]); + + // Handle focus management when rows are added + useEffect(() => { + + // Update the previous row count reference + // Note: We no longer automatically focus the first field of new rows + // as focus should remain on the "Add" button for better UX + prevRowCountRef.current = rowCount; + }, [ rowCount ]); + + // 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 = `${instanceId.current}-row-${index}`; + const rowLabelId = `${rowGroupId}-label`; + + // Call the user's render prop function to get the React nodes for this row's cells. + const rowContent = children({ + rowIndex: index, + focusRef: createFocusRef(index), + rowGroupId, + firstColumnLabelId, + secondColumnLabelId, + rowLabelId, + firstColumnAriaLabelledBy: `${rowLabelId} ${firstColumnLabelId}`, + secondColumnAriaLabelledBy: secondColumnLabelId ? `${rowLabelId} ${secondColumnLabelId}` : undefined + }); + // 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(
); + } + } + + // Determine span based on number of children + const cellSpan = cells.length === 1 ? 10 : 5; + + return ( + + {/* Visually hidden but accessible label for this row group */} +
+ {rowGroupLabelPrefix} {rowNumber} +
+ + {/* Map over the user's components and wrap each one in a GridItem with dynamic spans. */} + {cells.map((cell, cellIndex) => ( + + {cell} + + ))} + {/* Automatically add the remove button as the last item in the row. */} + + + + + + ); +}; + +export default FieldBuilder; diff --git a/packages/module/src/FieldBuilder/index.ts b/packages/module/src/FieldBuilder/index.ts new file mode 100644 index 00000000..15dbe39b --- /dev/null +++ b/packages/module/src/FieldBuilder/index.ts @@ -0,0 +1,2 @@ +export { default } from './FieldBuilder'; +export * from './FieldBuilder'; \ No newline at end of file From aeb7b7a16c409bf1e8ef6a22f21fd8a0cf1ef041 Mon Sep 17 00:00:00 2001 From: Parthiv Krishnan Date: Thu, 31 Jul 2025 10:30:09 -0400 Subject: [PATCH 2/8] fix: incorporated accessibility feedback --- .../FieldBuilder/FieldBuilderExample.tsx | 95 ++++++-- .../FieldBuilderSelectExample.tsx | 95 ++++++-- .../module/src/FieldBuilder/FieldBuilder.tsx | 215 ++++++------------ 3 files changed, 230 insertions(+), 175 deletions(-) diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderExample.tsx index c1ae5567..41996140 100644 --- a/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderExample.tsx +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderExample.tsx @@ -16,13 +16,56 @@ export const FieldBuilderExample: React.FunctionComponent = () => { ]); // Handle adding a new contact row - const handleAddContact = () => { - setContacts([ ...contacts, { name: '', email: '' } ]); + const handleAddContact = (event: React.MouseEvent) => { + // eslint-disable-next-line no-console + console.log('Add button clicked:', event.currentTarget); + const newContacts = [ ...contacts, { name: '', email: '' } ]; + setContacts(newContacts); + + // Focus management: focus the first field of the new row + setTimeout(() => { + const newRowNumber = newContacts.length; + const newRowFirstInput = document.querySelector(`input[aria-label*="Row ${newRowNumber}"][aria-label*="Name"]`) as HTMLInputElement; + if (newRowFirstInput) { + newRowFirstInput.focus(); + } + }, 100); }; // Handle removing a contact row - const handleRemoveContact = (index: number) => { - setContacts(contacts.filter((_, i) => i !== index)); + const handleRemoveContact = (event: React.MouseEvent, index: number) => { + // eslint-disable-next-line no-console + console.log('Remove button clicked:', event.currentTarget, 'for index:', index); + const newContacts = contacts.filter((_, i) => i !== index); + setContacts(newContacts); + + // Focus management: avoid focusing on destructive actions + setTimeout(() => { + // If there are still contacts after removal + if (newContacts.length > 0) { + // If we removed the last row, focus the new last row's first input + if (index >= newContacts.length) { + const newLastRowIndex = newContacts.length; + const previousRowFirstInput = document.querySelector(`input[aria-label*="Row ${newLastRowIndex}"][aria-label*="Name"]`) as HTMLInputElement; + if (previousRowFirstInput) { + previousRowFirstInput.focus(); + } + } else { + // If we removed a middle row, focus the first input of the row that took its place + const newRowNumber = index + 1; + const sameIndexFirstInput = document.querySelector(`input[aria-label*="Row ${newRowNumber}"][aria-label*="Name"]`) as HTMLInputElement; + if (sameIndexFirstInput) { + sameIndexFirstInput.focus(); + } + } + } else { + // If this was the last contact, focus the add button + const addButton = document.querySelector('button[aria-label*="Add"]') as HTMLButtonElement; + if (addButton) { + addButton.focus(); + } + } + }, 100); }; // Handle updating contact data @@ -32,6 +75,29 @@ export const FieldBuilderExample: React.FunctionComponent = () => { setContacts(updatedContacts); }; + // Custom announcement for adding rows + const customAddAnnouncement = (rowNumber: number, rowGroupLabelPrefix: string) => `New ${rowGroupLabelPrefix.toLowerCase()} ${rowNumber} added.`; + + // Custom announcement for removing rows + const customRemoveAnnouncement = (rowNumber: number, rowGroupLabelPrefix: string) => { + const removedIndex = rowNumber - 1; + const removedContact = contacts[removedIndex]; + if (removedContact?.name) { + return `Removed ${rowGroupLabelPrefix.toLowerCase()} ${removedContact.name}.`; + } + return `${rowGroupLabelPrefix} ${rowNumber} removed.`; + }; + + // Custom aria-label for remove buttons + const customRemoveAriaLabel = (rowNumber: number, rowGroupLabelPrefix: string) => { + const contactIndex = rowNumber - 1; + const contact = contacts[contactIndex]; + if (contact?.name) { + return `Remove ${rowGroupLabelPrefix.toLowerCase()} ${contact.name}`; + } + return `Remove ${rowGroupLabelPrefix.toLowerCase()} in row ${rowNumber}`; + }; + return (
{ rowCount={contacts.length} onAddRow={handleAddContact} onRemoveRow={handleRemoveContact} - addButtonProps={{ - children: 'Add team member' - }} + onAddRowAnnouncement={customAddAnnouncement} + onRemoveRowAnnouncement={customRemoveAnnouncement} + removeButtonAriaLabel={customRemoveAriaLabel} + addButtonContent="Add team member" > - {({ rowIndex, focusRef, firstColumnAriaLabelledBy, secondColumnAriaLabelledBy }) => [ + {({ focusRef, firstColumnAriaLabel, secondColumnAriaLabel }, index) => [ handleContactChange(rowIndex, 'name', value)} - aria-labelledby={firstColumnAriaLabelledBy} + onChange={(_event, value) => handleContactChange(index, 'name', value)} + aria-label={firstColumnAriaLabel} isRequired />, handleContactChange(rowIndex, 'email', value)} - aria-labelledby={secondColumnAriaLabelledBy} + onChange={(_event, value) => handleContactChange(index, 'email', value)} + aria-label={secondColumnAriaLabel} isRequired /> ]} diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderSelectExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderSelectExample.tsx index 7dc900c3..6599e8b3 100644 --- a/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderSelectExample.tsx +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderSelectExample.tsx @@ -17,13 +17,56 @@ export const FieldBuilderSelectExample: React.FunctionComponent = () => { ]); // Handle adding a new team member row - const handleAddTeamMember = () => { - setTeamMembers([ ...teamMembers, { department: '', role: '' } ]); + const handleAddTeamMember = (event: React.MouseEvent) => { + // eslint-disable-next-line no-console + console.log('Add button clicked:', event.currentTarget); + const newTeamMembers = [ ...teamMembers, { department: '', role: '' } ]; + setTeamMembers(newTeamMembers); + + // Focus management: focus the first field of the new row + setTimeout(() => { + const newRowNumber = newTeamMembers.length; + const newRowFirstSelect = document.querySelector(`select[aria-label*="Team member ${newRowNumber}"][aria-label*="Department"]`) as HTMLSelectElement; + if (newRowFirstSelect) { + newRowFirstSelect.focus(); + } + }, 100); }; // Handle removing a team member row - const handleRemoveTeamMember = (index: number) => { - setTeamMembers(teamMembers.filter((_, i) => i !== index)); + const handleRemoveTeamMember = (event: React.MouseEvent, index: number) => { + // eslint-disable-next-line no-console + console.log('Remove button clicked:', event.currentTarget, 'for index:', index); + const newTeamMembers = teamMembers.filter((_, i) => i !== index); + setTeamMembers(newTeamMembers); + + // Focus management: avoid focusing on destructive actions + setTimeout(() => { + // If there are still team members after removal + if (newTeamMembers.length > 0) { + // If we removed the last row, focus the new last row's first select + if (index >= newTeamMembers.length) { + const newLastRowIndex = newTeamMembers.length; + const previousRowFirstSelect = document.querySelector(`select[aria-label*="Team member ${newLastRowIndex}"][aria-label*="Department"]`) as HTMLSelectElement; + if (previousRowFirstSelect) { + previousRowFirstSelect.focus(); + } + } else { + // If we removed a middle row, focus the first select of the row that took its place + const newRowNumber = index + 1; + const sameIndexFirstSelect = document.querySelector(`select[aria-label*="Team member ${newRowNumber}"][aria-label*="Department"]`) as HTMLSelectElement; + if (sameIndexFirstSelect) { + sameIndexFirstSelect.focus(); + } + } + } else { + // If this was the last team member, focus the add button + const addButton = document.querySelector('button[aria-label*="Add"]') as HTMLButtonElement; + if (addButton) { + addButton.focus(); + } + } + }, 100); }; // Handle updating team member data @@ -33,6 +76,29 @@ export const FieldBuilderSelectExample: React.FunctionComponent = () => { setTeamMembers(updatedTeamMembers); }; + // Custom announcement for adding rows + const customAddAnnouncement = (rowNumber: number, rowGroupLabelPrefix: string) => `New ${rowGroupLabelPrefix.toLowerCase()} ${rowNumber} added.`; + + // Custom announcement for removing rows + const customRemoveAnnouncement = (rowNumber: number, rowGroupLabelPrefix: string) => { + const removedIndex = rowNumber - 1; + const removedTeamMember = teamMembers[removedIndex]; + if (removedTeamMember?.department && removedTeamMember?.role) { + return `Removed ${rowGroupLabelPrefix.toLowerCase()} ${removedTeamMember.role} from ${removedTeamMember.department}.`; + } + return `${rowGroupLabelPrefix} ${rowNumber} removed.`; + }; + + // Custom aria-label for remove buttons + const customRemoveAriaLabel = (rowNumber: number, rowGroupLabelPrefix: string) => { + const teamMemberIndex = rowNumber - 1; + const teamMember = teamMembers[teamMemberIndex]; + if (teamMember?.department && teamMember?.role) { + return `Remove ${rowGroupLabelPrefix.toLowerCase()} ${teamMember.role} from ${teamMember.department}`; + } + return `Remove ${rowGroupLabelPrefix.toLowerCase()} in row ${rowNumber}`; + }; + // Create a ref callback that works with FormSelect const createFormSelectRef = (focusRef: (element: HTMLElement | null) => void) => (instance: React.ComponentRef | HTMLElement | null) => { @@ -74,18 +140,19 @@ export const FieldBuilderSelectExample: React.FunctionComponent = () => { rowCount={teamMembers.length} onAddRow={handleAddTeamMember} onRemoveRow={handleRemoveTeamMember} + onAddRowAnnouncement={customAddAnnouncement} + onRemoveRowAnnouncement={customRemoveAnnouncement} + removeButtonAriaLabel={customRemoveAriaLabel} rowGroupLabelPrefix="Team member" - addButtonProps={{ - children: 'Add team member' - }} + addButtonContent="Add team member" > - {({ rowIndex, focusRef, firstColumnAriaLabelledBy, secondColumnAriaLabelledBy }) => [ + {({ focusRef, firstColumnAriaLabel, secondColumnAriaLabel }, index) => [ handleTeamMemberChange(rowIndex, 'department', value)} - aria-labelledby={firstColumnAriaLabelledBy} + value={teamMembers[index]?.department || ''} + onChange={(event, value) => handleTeamMemberChange(index, 'department', value)} + aria-label={firstColumnAriaLabel} isRequired > {departmentOptions.map((option, optionIndex) => ( @@ -99,9 +166,9 @@ export const FieldBuilderSelectExample: React.FunctionComponent = () => { , handleTeamMemberChange(rowIndex, 'role', value)} - aria-labelledby={secondColumnAriaLabelledBy} + value={teamMembers[index]?.role || ''} + onChange={(event, value) => handleTeamMemberChange(index, 'role', value)} + aria-label={secondColumnAriaLabel} isRequired > {roleOptions.map((option, optionIndex) => ( diff --git a/packages/module/src/FieldBuilder/FieldBuilder.tsx b/packages/module/src/FieldBuilder/FieldBuilder.tsx index 526f7b84..c55c63e8 100644 --- a/packages/module/src/FieldBuilder/FieldBuilder.tsx +++ b/packages/module/src/FieldBuilder/FieldBuilder.tsx @@ -1,4 +1,4 @@ -import React, { FunctionComponent, Children, useRef, useEffect, useCallback } from 'react'; +import React, { FunctionComponent, Children, useRef, useCallback, useState } from 'react'; import { Button, ButtonProps, @@ -13,39 +13,26 @@ import { PlusCircleIcon, MinusCircleIcon } from '@patternfly/react-icons'; /** * Defines the helpers passed to the children render prop. - * This allows each rendered row to have access to its own specific index. + * This provides accessibility labels and focus management for each row. */ export interface FieldRowHelpers { - rowIndex: number; /** * 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 - use this for aria-labelledby associations + * Unique ID for this row group */ rowGroupId: string; /** - * ID for the first column label - use this to associate the first field with its column header + * Complete aria-label string for the first column that includes both row and column context */ - firstColumnLabelId: string; + firstColumnAriaLabel: string; /** - * ID for the second column label - use this to associate the second field with its column header + * Complete aria-label string for the second column that includes both row and column context */ - secondColumnLabelId?: string; - /** - * ID for the row label - use this in combination with column labels for comprehensive labeling - */ - rowLabelId: string; - /** - * Complete aria-labelledby string for the first column that includes both row and column context - */ - firstColumnAriaLabelledBy: string; - /** - * Complete aria-labelledby string for the second column that includes both row and column context - */ - secondColumnAriaLabelledBy?: string; + secondColumnAriaLabel?: string; } /** @@ -63,14 +50,24 @@ export interface FieldBuilderProps extends Omit { * 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) => React.ReactNode; + children: (helpers: FieldRowHelpers, index: number) => React.ReactNode; /** A callback triggered when the "Add" button is clicked. */ - onAddRow: () => void; + onAddRow: (event: React.MouseEvent) => void; /** A callback triggered when a "Remove" button is clicked, which receives the index of the row to remove. */ - onRemoveRow: (index: number) => void; - /** Optional props to customize the "Add" button. */ + 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; + /** + * Optional function to customize the aria-label for remove buttons. + * If not provided, defaults to "Remove {rowGroupLabelPrefix} {rowNumber}". + */ + removeButtonAriaLabel?: (rowNumber: number, rowGroupLabelPrefix: string) => string; /** * Optional label prefix for each row group. Defaults to "Row". * Screen readers will announce this as "Row 1", "Row 2", etc. @@ -81,6 +78,16 @@ export interface FieldBuilderProps extends Omit { * This ensures unique IDs when multiple FieldBuilders exist on the same page. */ fieldBuilderIdPrefix?: string; + /** + * Optional function 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; + /** + * Optional function to customize the announcement message when a row is removed. + * If not provided, defaults to "{rowGroupLabelPrefix} {removedRowNumber} removed." + */ + onRemoveRowAnnouncement?: (rowNumber: number, rowGroupLabelPrefix: string) => string; } /** @@ -96,37 +103,27 @@ export const FieldBuilder: FunctionComponent = ({ onAddRow, onRemoveRow, addButtonProps = {}, + addButtonContent, + removeButtonProps = {}, + removeButtonAriaLabel, rowGroupLabelPrefix = "Row", fieldBuilderIdPrefix = "field-builder", + onAddRowAnnouncement, + onRemoveRowAnnouncement, ...formGroupProps }: FieldBuilderProps) => { - // Track the previous row count to detect when rows are added - const prevRowCountRef = useRef(rowCount); - // Track focusable elements for each row + // Track focusable elements for each row (for consumers who want to use focusRef) const focusableElementsRef = useRef>(new Map()); - // Track remove button refs for focus management - const removeButtonRefs = useRef>(new Map()); - // Track the add button ref - const addButtonRef = useRef(null); - // ARIA live region for announcing dynamic changes - const liveRegionRef = useRef(null); - - // Generate unique IDs for this instance - const instanceId = useRef(`${fieldBuilderIdPrefix}-${Math.random().toString(36).substr(2, 9)}`); - const firstColumnLabelId = `${instanceId.current}-first-column-label`; - const secondColumnLabelId = secondColumnLabel ? `${instanceId.current}-second-column-label` : undefined; + // State for ARIA live region announcements + const [ liveRegionMessage, setLiveRegionMessage ] = useState(''); // Function to announce changes to screen readers const announceChange = useCallback((message: string) => { - if (liveRegionRef.current) { - liveRegionRef.current.textContent = message; - // Clear the message after a delay to prepare for next announcement - setTimeout(() => { - if (liveRegionRef.current) { - liveRegionRef.current.textContent = ''; - } - }, 1000); - } + setLiveRegionMessage(message); + // Clear the message after a delay to prepare for next announcement + setTimeout(() => { + setLiveRegionMessage(''); + }, 1000); }, []); // Create ref callback for focusable elements @@ -139,71 +136,24 @@ export const FieldBuilder: FunctionComponent = ({ } }, []); - // Create ref callback for remove buttons - const createRemoveButtonRef = useCallback((rowIndex: number) => - (element: HTMLButtonElement | null) => { - if (element) { - removeButtonRefs.current.set(rowIndex, element); - } else { - removeButtonRefs.current.delete(rowIndex); - } - }, []); - // Enhanced onAddRow with focus management and announcements - const handleAddRow = useCallback(() => { - onAddRow(); - announceChange(`New ${rowGroupLabelPrefix.toLowerCase()} added. ${rowGroupLabelPrefix} ${rowCount + 1}.`); - }, [ onAddRow, announceChange, rowGroupLabelPrefix, rowCount ]); - - // Enhanced onRemoveRow with focus management and announcements - const handleRemoveRow = useCallback((index: number) => { + 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 + const handleRemoveRow = useCallback((event: React.MouseEvent, index: number) => { const rowNumber = index + 1; - // Determine where focus should go after removal - const focusTarget = () => { - // If removing the last row and there are other rows, focus the new last row's remove button - if (index === rowCount - 1 && rowCount > 1) { - return removeButtonRefs.current.get(index - 1); - } - // If removing a middle row, focus the remove button that will take its place (same index) - else if (index < rowCount - 1) { - // Give React time to re-render, then focus the remove button at the same index - setTimeout(() => { - const newButton = removeButtonRefs.current.get(index); - if (newButton) { - newButton.focus(); - } - }, 0); - return null; // Return null to skip immediate focus - } - // If removing the only row, focus the add button - else { - return addButtonRef.current; - } - }; - - const elementToFocus = focusTarget(); - onRemoveRow(index); + onRemoveRow(event, index); // Announce the removal - announceChange(`${rowGroupLabelPrefix} ${rowNumber} removed.`); - - // Focus immediately if we have a target (for last row or only row cases) - if (elementToFocus) { - setTimeout(() => { - elementToFocus.focus(); - }, 0); - } - }, [ onRemoveRow, rowCount, announceChange, rowGroupLabelPrefix ]); - - // Handle focus management when rows are added - useEffect(() => { - - // Update the previous row count reference - // Note: We no longer automatically focus the first field of new rows - // as focus should remain on the "Add" button for better UX - prevRowCountRef.current = rowCount; - }, [ rowCount ]); + 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 = () => { @@ -211,20 +161,15 @@ export const FieldBuilder: FunctionComponent = ({ return rows.map((_, index) => { const rowNumber = index + 1; - const rowGroupId = `${instanceId.current}-row-${index}`; - const rowLabelId = `${rowGroupId}-label`; + const rowGroupId = `${fieldBuilderIdPrefix}-row-${index}`; // Call the user's render prop function to get the React nodes for this row's cells. const rowContent = children({ - rowIndex: index, focusRef: createFocusRef(index), rowGroupId, - firstColumnLabelId, - secondColumnLabelId, - rowLabelId, - firstColumnAriaLabelledBy: `${rowLabelId} ${firstColumnLabelId}`, - secondColumnAriaLabelledBy: secondColumnLabelId ? `${rowLabelId} ${secondColumnLabelId}` : undefined - }); + 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); @@ -247,13 +192,7 @@ export const FieldBuilder: FunctionComponent = ({ hasGutter className="pf-v6-u-mb-md" role="group" - aria-labelledby={rowLabelId} > - {/* Visually hidden but accessible label for this row group */} -
- {rowGroupLabelPrefix} {rowNumber} -
- {/* Map over the user's components and wrap each one in a GridItem with dynamic spans. */} {cells.map((cell, cellIndex) => ( @@ -263,11 +202,11 @@ export const FieldBuilder: FunctionComponent = ({ {/* Automatically add the remove button as the last item in the row. */} From eda5df90b562e11ec43ebe13da7e86f8bbe05019 Mon Sep 17 00:00:00 2001 From: Parthiv Krishnan Date: Thu, 31 Jul 2025 15:51:32 -0400 Subject: [PATCH 3/8] fix: added column labels --- .../module/src/FieldBuilder/FieldBuilder.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/module/src/FieldBuilder/FieldBuilder.tsx b/packages/module/src/FieldBuilder/FieldBuilder.tsx index c55c63e8..17fa58d6 100644 --- a/packages/module/src/FieldBuilder/FieldBuilder.tsx +++ b/packages/module/src/FieldBuilder/FieldBuilder.tsx @@ -228,6 +228,24 @@ export const FieldBuilder: FunctionComponent = ({ {liveRegionMessage}
+ {/* Render the column headers */} + + + + {firstColumnLabel} + + + {secondColumnLabel && ( + + + {secondColumnLabel} + + + )} + {/* Empty GridItem to align with the remove button column */} + + + {/* Render all the dynamic rows of fields */} {renderRows()} From 7deaff0d6f753ff5857739b36d52077b518ec689 Mon Sep 17 00:00:00 2001 From: Parthiv Krishnan Date: Tue, 5 Aug 2025 14:40:10 -0400 Subject: [PATCH 4/8] fix: switched from grid to table --- .../FieldBuilder/FieldBuilderExample.tsx | 39 +---- .../FieldBuilderSelectExample.tsx | 37 ---- .../module/src/FieldBuilder/FieldBuilder.tsx | 158 ++++++++++++------ 3 files changed, 111 insertions(+), 123 deletions(-) diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderExample.tsx index 41996140..923c8508 100644 --- a/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderExample.tsx +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderExample.tsx @@ -21,15 +21,6 @@ export const FieldBuilderExample: React.FunctionComponent = () => { console.log('Add button clicked:', event.currentTarget); const newContacts = [ ...contacts, { name: '', email: '' } ]; setContacts(newContacts); - - // Focus management: focus the first field of the new row - setTimeout(() => { - const newRowNumber = newContacts.length; - const newRowFirstInput = document.querySelector(`input[aria-label*="Row ${newRowNumber}"][aria-label*="Name"]`) as HTMLInputElement; - if (newRowFirstInput) { - newRowFirstInput.focus(); - } - }, 100); }; // Handle removing a contact row @@ -38,34 +29,6 @@ export const FieldBuilderExample: React.FunctionComponent = () => { console.log('Remove button clicked:', event.currentTarget, 'for index:', index); const newContacts = contacts.filter((_, i) => i !== index); setContacts(newContacts); - - // Focus management: avoid focusing on destructive actions - setTimeout(() => { - // If there are still contacts after removal - if (newContacts.length > 0) { - // If we removed the last row, focus the new last row's first input - if (index >= newContacts.length) { - const newLastRowIndex = newContacts.length; - const previousRowFirstInput = document.querySelector(`input[aria-label*="Row ${newLastRowIndex}"][aria-label*="Name"]`) as HTMLInputElement; - if (previousRowFirstInput) { - previousRowFirstInput.focus(); - } - } else { - // If we removed a middle row, focus the first input of the row that took its place - const newRowNumber = index + 1; - const sameIndexFirstInput = document.querySelector(`input[aria-label*="Row ${newRowNumber}"][aria-label*="Name"]`) as HTMLInputElement; - if (sameIndexFirstInput) { - sameIndexFirstInput.focus(); - } - } - } else { - // If this was the last contact, focus the add button - const addButton = document.querySelector('button[aria-label*="Add"]') as HTMLButtonElement; - if (addButton) { - addButton.focus(); - } - } - }, 100); }; // Handle updating contact data @@ -112,7 +75,7 @@ export const FieldBuilderExample: React.FunctionComponent = () => { onAddRowAnnouncement={customAddAnnouncement} onRemoveRowAnnouncement={customRemoveAnnouncement} removeButtonAriaLabel={customRemoveAriaLabel} - addButtonContent="Add team member" + addButtonContent="Add contact" > {({ focusRef, firstColumnAriaLabel, secondColumnAriaLabel }, index) => [ { console.log('Add button clicked:', event.currentTarget); const newTeamMembers = [ ...teamMembers, { department: '', role: '' } ]; setTeamMembers(newTeamMembers); - - // Focus management: focus the first field of the new row - setTimeout(() => { - const newRowNumber = newTeamMembers.length; - const newRowFirstSelect = document.querySelector(`select[aria-label*="Team member ${newRowNumber}"][aria-label*="Department"]`) as HTMLSelectElement; - if (newRowFirstSelect) { - newRowFirstSelect.focus(); - } - }, 100); }; // Handle removing a team member row @@ -39,34 +30,6 @@ export const FieldBuilderSelectExample: React.FunctionComponent = () => { console.log('Remove button clicked:', event.currentTarget, 'for index:', index); const newTeamMembers = teamMembers.filter((_, i) => i !== index); setTeamMembers(newTeamMembers); - - // Focus management: avoid focusing on destructive actions - setTimeout(() => { - // If there are still team members after removal - if (newTeamMembers.length > 0) { - // If we removed the last row, focus the new last row's first select - if (index >= newTeamMembers.length) { - const newLastRowIndex = newTeamMembers.length; - const previousRowFirstSelect = document.querySelector(`select[aria-label*="Team member ${newLastRowIndex}"][aria-label*="Department"]`) as HTMLSelectElement; - if (previousRowFirstSelect) { - previousRowFirstSelect.focus(); - } - } else { - // If we removed a middle row, focus the first select of the row that took its place - const newRowNumber = index + 1; - const sameIndexFirstSelect = document.querySelector(`select[aria-label*="Team member ${newRowNumber}"][aria-label*="Department"]`) as HTMLSelectElement; - if (sameIndexFirstSelect) { - sameIndexFirstSelect.focus(); - } - } - } else { - // If this was the last team member, focus the add button - const addButton = document.querySelector('button[aria-label*="Add"]') as HTMLButtonElement; - if (addButton) { - addButton.focus(); - } - } - }, 100); }; // Handle updating team member data diff --git a/packages/module/src/FieldBuilder/FieldBuilder.tsx b/packages/module/src/FieldBuilder/FieldBuilder.tsx index 17fa58d6..b522db52 100644 --- a/packages/module/src/FieldBuilder/FieldBuilder.tsx +++ b/packages/module/src/FieldBuilder/FieldBuilder.tsx @@ -1,4 +1,4 @@ -import React, { FunctionComponent, Children, useRef, useCallback, useState } from 'react'; +import React, { FunctionComponent, Children, useRef, useCallback, useState, useEffect } from 'react'; import { Button, ButtonProps, @@ -6,9 +6,8 @@ import { type FormGroupProps, Flex, FlexItem, - Grid, - GridItem, } from '@patternfly/react-core'; +import { Table, Tbody, Td, Th, Tr, Thead } from '@patternfly/react-table'; import { PlusCircleIcon, MinusCircleIcon } from '@patternfly/react-icons'; /** @@ -116,6 +115,12 @@ export const FieldBuilder: FunctionComponent = ({ 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) => { @@ -126,6 +131,49 @@ export const FieldBuilder: FunctionComponent = ({ }, 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 + const newRowIndex = rowCount - 1; + const newRowFirstElement = focusableElementsRef.current.get(newRowIndex); + if (newRowFirstElement) { + newRowFirstElement.focus(); + } + } else if (rowCount < previousRowCount && lastRemovedIndexRef.current !== null) { + // Row was removed - apply smart focus logic + 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; + } + + // Update the previous row count + previousRowCountRef.current = rowCount; + }, [ rowCount ]); + // Create ref callback for focusable elements const createFocusRef = useCallback((rowIndex: number) => (element: HTMLElement | null) => { @@ -144,10 +192,13 @@ export const FieldBuilder: FunctionComponent = ({ announceChange(announcementMessage); }, [ onAddRow, announceChange, rowGroupLabelPrefix, rowCount, onAddRowAnnouncement ]); - // Enhanced onRemoveRow with announcements + // 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 @@ -183,24 +234,26 @@ export const FieldBuilder: FunctionComponent = ({ } } - // Determine span based on number of children - const cellSpan = cells.length === 1 ? 10 : 5; - return ( - - {/* Map over the user's components and wrap each one in a GridItem with dynamic spans. */} - {cells.map((cell, cellIndex) => ( - - {cell} - - ))} - {/* Automatically add the remove button as the last item in the row. */} - + + {/* First column cell */} + + {cells[0]} + + {/* Second column cell (if two-column layout) */} + {secondColumnLabel && ( + + {cells[1] ||
} + + )} + {/* Remove button column */} +