diff --git a/packages/module/src/FieldBuilder/FieldBuilder.tsx b/packages/module/src/FieldBuilder/FieldBuilder.tsx index a2d29965..0503451c 100644 --- a/packages/module/src/FieldBuilder/FieldBuilder.tsx +++ b/packages/module/src/FieldBuilder/FieldBuilder.tsx @@ -1,12 +1,5 @@ import React, { FunctionComponent, Children, useRef, useCallback, useState, useEffect } from 'react'; -import { - Button, - ButtonProps, - FormGroup, - type FormGroupProps, - Flex, - FlexItem, -} from '@patternfly/react-core'; +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'; @@ -15,7 +8,7 @@ import { PlusCircleIcon, MinusCircleIcon } from '@patternfly/react-icons'; * 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. */ @@ -39,9 +32,13 @@ export interface FieldRowHelpers { * label, helperText, isRequired, and validation states. */ export interface FieldBuilderProps extends Omit { - /** Label for the first column (required for both single and two-column layouts) */ + /** 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 (optional, only used in two-column layout) */ + /** 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; @@ -63,27 +60,27 @@ export interface FieldBuilderProps extends Omit { /** Additional props to customize the "Remove" buttons. */ removeButtonProps?: Omit; /** - * Optional function to customize the aria-label for remove buttons. + * Callback 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. + * 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; /** - * Optional unique ID prefix for this FieldBuilder instance. + * Unique ID prefix for this FieldBuilder instance. * 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. + * 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; /** - * Optional function to customize the announcement message when a row is removed. + * 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; @@ -95,6 +92,8 @@ export interface FieldBuilderProps extends Omit { * 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, @@ -105,8 +104,8 @@ export const FieldBuilder: FunctionComponent = ({ addButtonContent, removeButtonProps = {}, removeButtonAriaLabel, - rowGroupLabelPrefix = "Row", - fieldBuilderIdPrefix = "field-builder", + rowGroupLabelPrefix = 'Row', + fieldBuilderIdPrefix = 'field-builder', onAddRowAnnouncement, onRemoveRowAnnouncement, ...formGroupProps @@ -134,7 +133,7 @@ export const FieldBuilder: FunctionComponent = ({ // 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 @@ -150,7 +149,7 @@ export const FieldBuilder: FunctionComponent = ({ // 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) { @@ -170,47 +169,59 @@ export const FieldBuilder: FunctionComponent = ({ 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) => { + 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 ]); + 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 ]); + 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 = () => { @@ -222,12 +233,17 @@ export const FieldBuilder: FunctionComponent = ({ 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); + 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); @@ -241,20 +257,24 @@ export const FieldBuilder: FunctionComponent = ({ } } + 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] ||
} @@ -263,7 +283,11 @@ export const FieldBuilder: FunctionComponent = ({