Skip to content

Commit 9e2f0d9

Browse files
thatblindgeyeEric Olkowski
andauthored
fix(FieldBuilder): allow finer customization of accessible name (#816)
* fix(FieldBuilder): allow finer customization of accessible name * Fixed lint errors --------- Co-authored-by: Eric Olkowski <git.eric@thatblindgeye.dev>
1 parent 01512eb commit 9e2f0d9

File tree

1 file changed

+97
-76
lines changed

1 file changed

+97
-76
lines changed

packages/module/src/FieldBuilder/FieldBuilder.tsx

Lines changed: 97 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,5 @@
11
import 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';
103
import { Table, Tbody, Td, Th, Tr, Thead } from '@patternfly/react-table';
114
import { 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
*/
1710
export 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
*/
4134
export 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
*/
9794
export 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

Comments
 (0)