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..a02f549d --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderExample.tsx @@ -0,0 +1,104 @@ +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 = (event: React.MouseEvent) => { + // eslint-disable-next-line no-console + console.log('Add button clicked:', event.currentTarget); + const newContacts = [ ...contacts, { name: '', email: '' } ]; + setContacts(newContacts); + }; + + // Handle removing a contact row + 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); + }; + + // Handle updating contact data + const handleContactChange = (index: number, field: keyof Contact, value: string) => { + const updatedContacts = [ ...contacts ]; + updatedContacts[index] = { ...updatedContacts[index], [field]: value }; + 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 ( +
+ + {({ focusRef, firstColumnAriaLabel, secondColumnAriaLabel }, index) => [ + handleContactChange(index, 'name', value)} + aria-label={firstColumnAriaLabel} + isRequired + />, + handleContactChange(index, 'email', value)} + aria-label={secondColumnAriaLabel} + 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..d7e55f17 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderSelectExample.tsx @@ -0,0 +1,244 @@ +import React, { useState } from 'react'; +import { + Form, + Select, + SelectOption, + SelectList, + MenuToggle, + MenuToggleElement, +} 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: '' } + ]); + + // State for managing which select dropdowns are open + const [ departmentOpenStates, setDepartmentOpenStates ] = useState([ false ]); + const [ roleOpenStates, setRoleOpenStates ] = useState([ false ]); + + // Handle adding a new team member row + 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); + // Add new open states for the selects + setDepartmentOpenStates([ ...departmentOpenStates, false ]); + setRoleOpenStates([ ...roleOpenStates, false ]); + }; + + // Handle removing a team member row + 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); + // Remove corresponding open states + setDepartmentOpenStates(departmentOpenStates.filter((_, i) => i !== index)); + setRoleOpenStates(roleOpenStates.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); + }; + + // Handle department select open/close + const handleDepartmentToggle = (index: number) => { + const newOpenStates = [ ...departmentOpenStates ]; + newOpenStates[index] = !newOpenStates[index]; + setDepartmentOpenStates(newOpenStates); + }; + + // Handle role select open/close + const handleRoleToggle = (index: number) => { + const newOpenStates = [ ...roleOpenStates ]; + newOpenStates[index] = !newOpenStates[index]; + setRoleOpenStates(newOpenStates); + }; + + // Handle department selection + const handleDepartmentSelect = (index: number, _event: React.MouseEvent | undefined, value: string | number | undefined) => { + handleTeamMemberChange(index, 'department', value as string); + const newOpenStates = [ ...departmentOpenStates ]; + newOpenStates[index] = false; + setDepartmentOpenStates(newOpenStates); + }; + + // Handle role selection + const handleRoleSelect = (index: number, _event: React.MouseEvent | undefined, value: string | number | undefined) => { + handleTeamMemberChange(index, 'role', value as string); + const newOpenStates = [ ...roleOpenStates ]; + newOpenStates[index] = false; + setRoleOpenStates(newOpenStates); + }; + + // 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}`; + }; + + 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 ( +
+ + {({ focusRef, firstColumnAriaLabel, secondColumnAriaLabel }, index) => [ + , + + ]} + +
+ ); +}; + +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..a2d29965 --- /dev/null +++ b/packages/module/src/FieldBuilder/FieldBuilder.tsx @@ -0,0 +1,340 @@ +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 { + /** 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). + * 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; + /** + * 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. + */ + rowGroupLabelPrefix?: string; + /** + * Optional 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. + * 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; +} + +/** + * 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 = {}, + 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(
); + } + } + + return ( + + {/* First column cell */} + + {cells[0]} + + {/* Second column cell (if two-column layout) */} + {secondColumnLabel && ( + + {cells[1] ||
} + + )} + {/* Remove button column */} + + + + + + ); +}; + +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