11import React , { FunctionComponent , Children , useRef , useCallback , useState , useEffect } from 'react' ;
2- import {
3- Button ,
4- ButtonProps ,
5- FormGroup ,
6- type FormGroupProps ,
7- Flex ,
8- FlexItem ,
9- } from '@patternfly/react-core' ;
2+ import { Button , ButtonProps , FormGroup , type FormGroupProps , Flex , FlexItem } from '@patternfly/react-core' ;
103import { Table , Tbody , Td , Th , Tr , Thead } from '@patternfly/react-table' ;
114import { PlusCircleIcon , MinusCircleIcon } from '@patternfly/react-icons' ;
125
@@ -15,7 +8,7 @@ import { PlusCircleIcon, MinusCircleIcon } from '@patternfly/react-icons';
158 * This provides accessibility labels and focus management for each row.
169 */
1710export interface FieldRowHelpers {
18- /**
11+ /**
1912 * Ref callback to attach to the first focusable element in the row.
2013 * This enables automatic focus management when rows are added/removed.
2114 */
@@ -39,9 +32,13 @@ export interface FieldRowHelpers {
3932 * label, helperText, isRequired, and validation states.
4033 */
4134export interface FieldBuilderProps extends Omit < FormGroupProps , 'children' > {
42- /** Label for the first column (required for both single and two-column layouts) */
35+ /** Provides an accessible name for the field builder table via a human readable string. */
36+ 'aria-label' ?: string ;
37+ /** Provides an accessible name for the field builder table via a space separated list of IDs. */
38+ 'aria-labelledby' ?: string ;
39+ /** Label for the first column */
4340 firstColumnLabel : React . ReactNode ;
44- /** Label for the second column (optional, only used in two-column layout) */
41+ /** Label for the second column in a two-column layout */
4542 secondColumnLabel ?: React . ReactNode ;
4643 /** The total number of rows to render. This should be derived from the length of the state array managed by the parent. */
4744 rowCount : number ;
@@ -63,27 +60,27 @@ export interface FieldBuilderProps extends Omit<FormGroupProps, 'children'> {
6360 /** Additional props to customize the "Remove" buttons. */
6461 removeButtonProps ?: Omit < ButtonProps , 'onClick' | 'ref' > ;
6562 /**
66- * Optional function to customize the aria-label for remove buttons.
63+ * Callback to customize the aria-label for remove buttons.
6764 * If not provided, defaults to "Remove {rowGroupLabelPrefix} {rowNumber}".
6865 */
6966 removeButtonAriaLabel ?: ( rowNumber : number , rowGroupLabelPrefix : string ) => string ;
7067 /**
71- * Optional label prefix for each row group. Defaults to "Row".
72- * Screen readers will announce this as "Row 1", "Row 2", etc .
68+ * Label prefix for each row group. Defaults to "Row". This is also used to create a default
69+ * Table accessible name when the aria-label nor aria-labelledby are not provided .
7370 */
7471 rowGroupLabelPrefix ?: string ;
7572 /**
76- * Optional unique ID prefix for this FieldBuilder instance.
73+ * Unique ID prefix for this FieldBuilder instance.
7774 * This ensures unique IDs when multiple FieldBuilders exist on the same page.
7875 */
7976 fieldBuilderIdPrefix ?: string ;
8077 /**
81- * Optional function to customize the announcement message when a row is added.
78+ * Callback to customize the announcement message when a row is added.
8279 * If not provided, defaults to "New {rowGroupLabelPrefix} added. {rowGroupLabelPrefix} {newRowNumber}."
8380 */
8481 onAddRowAnnouncement ?: ( rowNumber : number , rowGroupLabelPrefix : string ) => string ;
8582 /**
86- * Optional function to customize the announcement message when a row is removed.
83+ * Callback to customize the announcement message when a row is removed.
8784 * If not provided, defaults to "{rowGroupLabelPrefix} {removedRowNumber} removed."
8885 */
8986 onRemoveRowAnnouncement ?: ( rowNumber : number , rowGroupLabelPrefix : string ) => string ;
@@ -95,6 +92,8 @@ export interface FieldBuilderProps extends Omit<FormGroupProps, 'children'> {
9592 * for adding and removing rows, while giving the consumer full control over the fields themselves.
9693 */
9794export const FieldBuilder : FunctionComponent < FieldBuilderProps > = ( {
95+ 'aria-label' : ariaLabel ,
96+ 'aria-labelledby' : ariaLabelledby ,
9897 firstColumnLabel,
9998 secondColumnLabel,
10099 rowCount,
@@ -105,8 +104,8 @@ export const FieldBuilder: FunctionComponent<FieldBuilderProps> = ({
105104 addButtonContent,
106105 removeButtonProps = { } ,
107106 removeButtonAriaLabel,
108- rowGroupLabelPrefix = " Row" ,
109- fieldBuilderIdPrefix = " field-builder" ,
107+ rowGroupLabelPrefix = ' Row' ,
108+ fieldBuilderIdPrefix = ' field-builder' ,
110109 onAddRowAnnouncement,
111110 onRemoveRowAnnouncement,
112111 ...formGroupProps
@@ -134,7 +133,7 @@ export const FieldBuilder: FunctionComponent<FieldBuilderProps> = ({
134133 // Focus management effect - runs when rowCount changes
135134 useEffect ( ( ) => {
136135 const previousRowCount = previousRowCountRef . current ;
137-
136+
138137 if ( rowCount > previousRowCount ) {
139138 // Row was added - focus the first input of the new row
140139 // Use setTimeout to ensure DOM is fully rendered for complex components like Select
@@ -150,7 +149,7 @@ export const FieldBuilder: FunctionComponent<FieldBuilderProps> = ({
150149 // Use setTimeout to ensure DOM is fully updated after row removal
151150 setTimeout ( ( ) => {
152151 const removedIndex = lastRemovedIndexRef . current ! ;
153-
152+
154153 if ( rowCount === 0 ) {
155154 // No rows left - focus the add button
156155 if ( addButtonRef . current ) {
@@ -170,47 +169,59 @@ export const FieldBuilder: FunctionComponent<FieldBuilderProps> = ({
170169 sameIndexFirstElement . focus ( ) ;
171170 }
172171 }
173-
172+
174173 // Reset the removed index tracker
175174 lastRemovedIndexRef . current = null ;
176175 } , 0 ) ;
177176 }
178-
177+
179178 // Update the previous row count
180179 previousRowCountRef . current = rowCount ;
181180 } , [ rowCount ] ) ;
182181
183182 // Create ref callback for focusable elements
184- const createFocusRef = useCallback ( ( rowIndex : number ) =>
185- ( element : HTMLElement | null ) => {
183+ const createFocusRef = useCallback (
184+ ( rowIndex : number ) => ( element : HTMLElement | null ) => {
186185 if ( element ) {
187186 focusableElementsRef . current . set ( rowIndex , element ) ;
188187 } else {
189188 focusableElementsRef . current . delete ( rowIndex ) ;
190189 }
191- } , [ ] ) ;
190+ } ,
191+ [ ]
192+ ) ;
192193
193194 // Enhanced onAddRow with focus management and announcements
194- const handleAddRow = useCallback ( ( event : React . MouseEvent ) => {
195- onAddRow ( event ) ;
196- const newRowNumber = rowCount + 1 ;
197- const announcementMessage = onAddRowAnnouncement ? onAddRowAnnouncement ( newRowNumber , rowGroupLabelPrefix ) : `New ${ rowGroupLabelPrefix . toLowerCase ( ) } added. ${ rowGroupLabelPrefix } ${ newRowNumber } .` ;
198- announceChange ( announcementMessage ) ;
199- } , [ onAddRow , announceChange , rowGroupLabelPrefix , rowCount , onAddRowAnnouncement ] ) ;
195+ const handleAddRow = useCallback (
196+ ( event : React . MouseEvent ) => {
197+ onAddRow ( event ) ;
198+ const newRowNumber = rowCount + 1 ;
199+ const announcementMessage = onAddRowAnnouncement
200+ ? onAddRowAnnouncement ( newRowNumber , rowGroupLabelPrefix )
201+ : `New ${ rowGroupLabelPrefix . toLowerCase ( ) } added. ${ rowGroupLabelPrefix } ${ newRowNumber } .` ;
202+ announceChange ( announcementMessage ) ;
203+ } ,
204+ [ onAddRow , announceChange , rowGroupLabelPrefix , rowCount , onAddRowAnnouncement ]
205+ ) ;
200206
201207 // Enhanced onRemoveRow with announcements and focus tracking
202- const handleRemoveRow = useCallback ( ( event : React . MouseEvent , index : number ) => {
203- const rowNumber = index + 1 ;
204-
205- // Track which row is being removed for focus management
206- lastRemovedIndexRef . current = index ;
207-
208- onRemoveRow ( event , index ) ;
209-
210- // Announce the removal
211- const announcementMessage = onRemoveRowAnnouncement ? onRemoveRowAnnouncement ( rowNumber , rowGroupLabelPrefix ) : `${ rowGroupLabelPrefix } ${ rowNumber } removed.` ;
212- announceChange ( announcementMessage ) ;
213- } , [ onRemoveRow , announceChange , rowGroupLabelPrefix , onRemoveRowAnnouncement ] ) ;
208+ const handleRemoveRow = useCallback (
209+ ( event : React . MouseEvent , index : number ) => {
210+ const rowNumber = index + 1 ;
211+
212+ // Track which row is being removed for focus management
213+ lastRemovedIndexRef . current = index ;
214+
215+ onRemoveRow ( event , index ) ;
216+
217+ // Announce the removal
218+ const announcementMessage = onRemoveRowAnnouncement
219+ ? onRemoveRowAnnouncement ( rowNumber , rowGroupLabelPrefix )
220+ : `${ rowGroupLabelPrefix } ${ rowNumber } removed.` ;
221+ announceChange ( announcementMessage ) ;
222+ } ,
223+ [ onRemoveRow , announceChange , rowGroupLabelPrefix , onRemoveRowAnnouncement ]
224+ ) ;
214225
215226 // Helper function to render all the dynamic rows.
216227 const renderRows = ( ) => {
@@ -222,12 +233,17 @@ export const FieldBuilder: FunctionComponent<FieldBuilderProps> = ({
222233 const topPaddingClass = index > 0 ? 'pf-v6-u-pt-0' : '' ;
223234
224235 // Call the user's render prop function to get the React nodes for this row's cells.
225- const rowContent = children ( {
226- focusRef : createFocusRef ( index ) ,
227- rowGroupId,
228- firstColumnAriaLabel : `${ rowGroupLabelPrefix } ${ rowNumber } , ${ firstColumnLabel } ` ,
229- secondColumnAriaLabel : secondColumnLabel ? `${ rowGroupLabelPrefix } ${ rowNumber } , ${ secondColumnLabel } ` : undefined
230- } , index ) ;
236+ const rowContent = children (
237+ {
238+ focusRef : createFocusRef ( index ) ,
239+ rowGroupId,
240+ firstColumnAriaLabel : `${ rowGroupLabelPrefix } ${ rowNumber } , ${ firstColumnLabel } ` ,
241+ secondColumnAriaLabel : secondColumnLabel
242+ ? `${ rowGroupLabelPrefix } ${ rowNumber } , ${ secondColumnLabel } `
243+ : undefined
244+ } ,
245+ index
246+ ) ;
231247 // Safely convert the returned content into an array of children.
232248 const cells = Children . toArray ( rowContent ) ;
233249
@@ -241,20 +257,24 @@ export const FieldBuilder: FunctionComponent<FieldBuilderProps> = ({
241257 }
242258 }
243259
260+ const preDeleteCellStyles = { paddingInlineEnd : 'var(--pf-t--global--spacer--xs)' } ;
261+
244262 return (
245263 < Tr key = { `field-row-${ index } ` } >
246264 { /* First column cell */ }
247- < Td
265+ < Td
248266 dataLabel = { String ( firstColumnLabel ) }
249267 className = { `${ secondColumnLabel ? 'pf-m-width-40' : 'pf-m-width-80' } ${ topPaddingClass } ` . trim ( ) }
268+ style = { secondColumnLabel ? undefined : preDeleteCellStyles }
250269 >
251270 { cells [ 0 ] }
252271 </ Td >
253272 { /* Second column cell (if two-column layout) */ }
254273 { secondColumnLabel && (
255- < Td
274+ < Td
256275 dataLabel = { String ( secondColumnLabel ) }
257276 className = { `pf-m-width-40 ${ topPaddingClass } ` . trim ( ) }
277+ style = { preDeleteCellStyles }
258278 >
259279 { cells [ 1 ] || < div /> }
260280 </ Td >
@@ -263,7 +283,11 @@ export const FieldBuilder: FunctionComponent<FieldBuilderProps> = ({
263283 < Td className = { `pf-m-width-20 ${ topPaddingClass } ` . trim ( ) } >
264284 < Button
265285 variant = "plain"
266- aria-label = { removeButtonAriaLabel ? removeButtonAriaLabel ( rowNumber , rowGroupLabelPrefix ) : `Remove ${ rowGroupLabelPrefix . toLowerCase ( ) } ${ rowNumber } ` }
286+ aria-label = {
287+ removeButtonAriaLabel
288+ ? removeButtonAriaLabel ( rowNumber , rowGroupLabelPrefix )
289+ : `Remove ${ rowGroupLabelPrefix . toLowerCase ( ) } ${ rowNumber } `
290+ }
267291 onClick = { ( event ) => handleRemoveRow ( event , index ) }
268292 icon = { < MinusCircleIcon /> }
269293 { ...removeButtonProps }
@@ -279,30 +303,29 @@ export const FieldBuilder: FunctionComponent<FieldBuilderProps> = ({
279303 < FormGroup { ...formGroupProps } >
280304 < Flex direction = { { default : 'column' } } spaceItems = { { default : 'spaceItemsNone' } } >
281305 { /* ARIA Live Region for announcing dynamic changes */ }
282- < div
283- className = "pf-v6-screen-reader"
284- aria-live = "polite"
285- >
306+ < div className = "pf-v6-screen-reader" aria-live = "polite" >
286307 { liveRegionMessage }
287308 </ div >
288309
289310 { /* Table layout */ }
290- < Table
291- aria-label = { `${ rowGroupLabelPrefix } management` }
292- variant = "compact"
311+ < Table
312+ aria-label = { ! ariaLabelledby ? ( ariaLabel ?? `${ rowGroupLabelPrefix } management` ) : ariaLabel }
313+ aria-labelledby = { ariaLabelledby }
314+ variant = "compact"
293315 borders = { false }
294- style = { {
295- '--pf-v6-c-table--cell--PaddingInlineStart' : '0' ,
296- '--pf-v6-c-table--cell--first-last-child--PaddingInline' : '0 1rem 0 0' ,
297- '--pf-v6-c-table--cell--PaddingBlockStart' : 'var(--pf-t--global--spacer--sm)' ,
298- '--pf-v6-c-table--cell--PaddingBlockEnd' : 'var(--pf-t--global--spacer--sm)' ,
299- '--pf-v6-c-table__thead--cell--PaddingBlockEnd' : '0' ,
300-
301- } as React . CSSProperties }
316+ style = {
317+ {
318+ '--pf-v6-c-table--cell--PaddingInlineStart' : '0' ,
319+ '--pf-v6-c-table--cell--first-last-child--PaddingInline' : '0 1rem 0 0' ,
320+ '--pf-v6-c-table--cell--PaddingBlockStart' : 'var(--pf-t--global--spacer--sm)' ,
321+ '--pf-v6-c-table--cell--PaddingBlockEnd' : 'var(--pf-t--global--spacer--sm)' ,
322+ '--pf-v6-c-table__thead--cell--PaddingBlockEnd' : '0'
323+ } as React . CSSProperties
324+ }
302325 >
303326 < Thead >
304327 < Tr >
305- < Th className = { secondColumnLabel ? " pf-m-width-40" : " pf-m-width-80" } style = { { paddingBottom : 0 } } >
328+ < Th className = { secondColumnLabel ? ' pf-m-width-40' : ' pf-m-width-80' } style = { { paddingBottom : 0 } } >
306329 { firstColumnLabel }
307330 </ Th >
308331 { secondColumnLabel && (
@@ -313,19 +336,17 @@ export const FieldBuilder: FunctionComponent<FieldBuilderProps> = ({
313336 < Th screenReaderText = "Actions" className = "pf-m-width-20" />
314337 </ Tr >
315338 </ Thead >
316- < Tbody >
317- { renderRows ( ) }
318- </ Tbody >
339+ < Tbody > { renderRows ( ) } </ Tbody >
319340 </ Table >
320341
321342 { /* The "Add" button for creating a new row */ }
322343 < FlexItem className = "pf-v6-u-mt-0" >
323344 { /* <FlexItem className="pf-v6-u-mt-sm"> */ }
324- < Button
345+ < Button
325346 ref = { addButtonRef }
326- variant = "link"
327- onClick = { handleAddRow }
328- icon = { < PlusCircleIcon /> }
347+ variant = "link"
348+ onClick = { handleAddRow }
349+ icon = { < PlusCircleIcon /> }
329350 aria-label = { `Add ${ rowGroupLabelPrefix . toLowerCase ( ) } ` }
330351 { ...addButtonProps }
331352 >
0 commit comments