Skip to content

Commit 01512eb

Browse files
authored
feat(component): Field builder component (#777)
* feat(component): Field builder component using grid layout * fix: incorporated accessibility feedback * fix: added column labels * fix: switched from grid to table * fix: uses Select instead of FormSelect and focus management fixed * fix: fixed accessibility issue * fix: added further context to aria labels for select example * fix: resolved spacing issues
1 parent 81f752e commit 01512eb

File tree

5 files changed

+720
-0
lines changed

5 files changed

+720
-0
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
section: Component groups
3+
subsection: Helpers
4+
id: Field Builder
5+
source: react
6+
propComponents: ['FieldBuilder']
7+
---
8+
9+
import { FunctionComponent, useState } from 'react';
10+
import { FieldBuilder } from '@patternfly/react-component-groups/dist/dynamic/FieldBuilder';
11+
import { MinusCircleIcon } from '@patternfly/react-icons';
12+
13+
14+
## Examples
15+
16+
### Basic Field Builder
17+
18+
This is a basic field builder!
19+
20+
```js file="./FieldBuilderExample.tsx"
21+
22+
```
23+
24+
### Field Builder Select
25+
26+
This is a field builder with Select components!
27+
28+
```js file="./FieldBuilderSelectExample.tsx"
29+
30+
```
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import React, { useState } from 'react';
2+
import {
3+
Form,
4+
TextInput,
5+
} from '@patternfly/react-core';
6+
import { FieldBuilder } from '@patternfly/react-component-groups/dist/dynamic/FieldBuilder';
7+
8+
interface Contact {
9+
name: string;
10+
email: string;
11+
}
12+
13+
export const FieldBuilderExample: React.FunctionComponent = () => {
14+
const [ contacts, setContacts ] = useState<Contact[]>([
15+
{ name: '', email: '' }
16+
]);
17+
18+
// Handle adding a new contact row
19+
const handleAddContact = (event: React.MouseEvent) => {
20+
// eslint-disable-next-line no-console
21+
console.log('Add button clicked:', event.currentTarget);
22+
const newContacts = [ ...contacts, { name: '', email: '' } ];
23+
setContacts(newContacts);
24+
};
25+
26+
// Handle removing a contact row
27+
const handleRemoveContact = (event: React.MouseEvent, index: number) => {
28+
// eslint-disable-next-line no-console
29+
console.log('Remove button clicked:', event.currentTarget, 'for index:', index);
30+
const newContacts = contacts.filter((_, i) => i !== index);
31+
setContacts(newContacts);
32+
};
33+
34+
// Handle updating contact data
35+
const handleContactChange = (index: number, field: keyof Contact, value: string) => {
36+
const updatedContacts = [ ...contacts ];
37+
updatedContacts[index] = { ...updatedContacts[index], [field]: value };
38+
setContacts(updatedContacts);
39+
};
40+
41+
// Custom announcement for adding rows
42+
const customAddAnnouncement = (rowNumber: number, rowGroupLabelPrefix: string) => `New ${rowGroupLabelPrefix.toLowerCase()} ${rowNumber} added.`;
43+
44+
// Custom announcement for removing rows
45+
const customRemoveAnnouncement = (rowNumber: number, rowGroupLabelPrefix: string) => {
46+
const removedIndex = rowNumber - 1;
47+
const removedContact = contacts[removedIndex];
48+
if (removedContact?.name) {
49+
return `Removed ${rowGroupLabelPrefix.toLowerCase()} ${removedContact.name}.`;
50+
}
51+
return `${rowGroupLabelPrefix} ${rowNumber} removed.`;
52+
};
53+
54+
// Custom aria-label for remove buttons
55+
const customRemoveAriaLabel = (rowNumber: number, rowGroupLabelPrefix: string) => {
56+
const contactIndex = rowNumber - 1;
57+
const contact = contacts[contactIndex];
58+
if (contact?.name) {
59+
return `Remove ${rowGroupLabelPrefix.toLowerCase()} ${contact.name}`;
60+
}
61+
return `Remove ${rowGroupLabelPrefix.toLowerCase()} in row ${rowNumber}`;
62+
};
63+
64+
return (
65+
<Form>
66+
<FieldBuilder
67+
isRequired
68+
firstColumnLabel="Name"
69+
secondColumnLabel="Email"
70+
rowCount={contacts.length}
71+
onAddRow={handleAddContact}
72+
onRemoveRow={handleRemoveContact}
73+
onAddRowAnnouncement={customAddAnnouncement}
74+
onRemoveRowAnnouncement={customRemoveAnnouncement}
75+
removeButtonAriaLabel={customRemoveAriaLabel}
76+
addButtonContent="Add contact"
77+
>
78+
{({ focusRef, firstColumnAriaLabel, secondColumnAriaLabel }, index) => [
79+
<TextInput
80+
key="name"
81+
ref={focusRef}
82+
type="text"
83+
value={contacts[index]?.name || ''}
84+
placeholder="Enter full name"
85+
onChange={(_event, value) => handleContactChange(index, 'name', value)}
86+
aria-label={firstColumnAriaLabel}
87+
isRequired
88+
/>,
89+
<TextInput
90+
key="email"
91+
type="email"
92+
value={contacts[index]?.email || ''}
93+
placeholder="name@example.com"
94+
onChange={(_event, value) => handleContactChange(index, 'email', value)}
95+
aria-label={secondColumnAriaLabel}
96+
isRequired
97+
/>
98+
]}
99+
</FieldBuilder>
100+
</Form>
101+
);
102+
};
103+
104+
export default FieldBuilderExample;
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import React, { useState } from 'react';
2+
import {
3+
Form,
4+
Select,
5+
SelectOption,
6+
SelectList,
7+
MenuToggle,
8+
MenuToggleElement,
9+
} from '@patternfly/react-core';
10+
import { FieldBuilder } from '@patternfly/react-component-groups/dist/dynamic/FieldBuilder';
11+
12+
interface TeamMember {
13+
department: string;
14+
role: string;
15+
}
16+
17+
export const FieldBuilderSelectExample: React.FunctionComponent = () => {
18+
const [ teamMembers, setTeamMembers ] = useState<TeamMember[]>([
19+
{ department: '', role: '' }
20+
]);
21+
22+
// State for managing which select dropdowns are open
23+
const [ departmentOpenStates, setDepartmentOpenStates ] = useState<boolean[]>([ false ]);
24+
const [ roleOpenStates, setRoleOpenStates ] = useState<boolean[]>([ false ]);
25+
26+
// Handle adding a new team member row
27+
const handleAddTeamMember = (event: React.MouseEvent) => {
28+
// eslint-disable-next-line no-console
29+
console.log('Add button clicked:', event.currentTarget);
30+
const newTeamMembers = [ ...teamMembers, { department: '', role: '' } ];
31+
setTeamMembers(newTeamMembers);
32+
// Add new open states for the selects
33+
setDepartmentOpenStates([ ...departmentOpenStates, false ]);
34+
setRoleOpenStates([ ...roleOpenStates, false ]);
35+
};
36+
37+
// Handle removing a team member row
38+
const handleRemoveTeamMember = (event: React.MouseEvent, index: number) => {
39+
// eslint-disable-next-line no-console
40+
console.log('Remove button clicked:', event.currentTarget, 'for index:', index);
41+
const newTeamMembers = teamMembers.filter((_, i) => i !== index);
42+
setTeamMembers(newTeamMembers);
43+
// Remove corresponding open states
44+
setDepartmentOpenStates(departmentOpenStates.filter((_, i) => i !== index));
45+
setRoleOpenStates(roleOpenStates.filter((_, i) => i !== index));
46+
};
47+
48+
// Handle updating team member data
49+
const handleTeamMemberChange = (index: number, field: keyof TeamMember, value: string) => {
50+
const updatedTeamMembers = [ ...teamMembers ];
51+
updatedTeamMembers[index] = { ...updatedTeamMembers[index], [field]: value };
52+
setTeamMembers(updatedTeamMembers);
53+
};
54+
55+
// Handle department select open/close
56+
const handleDepartmentToggle = (index: number) => {
57+
const newOpenStates = [ ...departmentOpenStates ];
58+
newOpenStates[index] = !newOpenStates[index];
59+
setDepartmentOpenStates(newOpenStates);
60+
};
61+
62+
// Handle role select open/close
63+
const handleRoleToggle = (index: number) => {
64+
const newOpenStates = [ ...roleOpenStates ];
65+
newOpenStates[index] = !newOpenStates[index];
66+
setRoleOpenStates(newOpenStates);
67+
};
68+
69+
// Handle department selection
70+
const handleDepartmentSelect = (index: number, _event: React.MouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => {
71+
handleTeamMemberChange(index, 'department', value as string);
72+
const newOpenStates = [ ...departmentOpenStates ];
73+
newOpenStates[index] = false;
74+
setDepartmentOpenStates(newOpenStates);
75+
};
76+
77+
// Handle role selection
78+
const handleRoleSelect = (index: number, _event: React.MouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => {
79+
handleTeamMemberChange(index, 'role', value as string);
80+
const newOpenStates = [ ...roleOpenStates ];
81+
newOpenStates[index] = false;
82+
setRoleOpenStates(newOpenStates);
83+
};
84+
85+
// Custom announcement for adding rows
86+
const customAddAnnouncement = (rowNumber: number, rowGroupLabelPrefix: string) => `New ${rowGroupLabelPrefix.toLowerCase()} ${rowNumber} added.`;
87+
88+
// Custom announcement for removing rows
89+
const customRemoveAnnouncement = (rowNumber: number, rowGroupLabelPrefix: string) => {
90+
const removedIndex = rowNumber - 1;
91+
const removedTeamMember = teamMembers[removedIndex];
92+
if (removedTeamMember?.department && removedTeamMember?.role) {
93+
return `Removed ${rowGroupLabelPrefix.toLowerCase()} ${removedTeamMember.role} from ${removedTeamMember.department}.`;
94+
}
95+
return `${rowGroupLabelPrefix} ${rowNumber} removed.`;
96+
};
97+
98+
// Custom aria-label for remove buttons
99+
const customRemoveAriaLabel = (rowNumber: number, rowGroupLabelPrefix: string) => {
100+
const teamMemberIndex = rowNumber - 1;
101+
const teamMember = teamMembers[teamMemberIndex];
102+
if (teamMember?.department && teamMember?.role) {
103+
return `Remove ${rowGroupLabelPrefix.toLowerCase()} ${teamMember.role} from ${teamMember.department}`;
104+
}
105+
return `Remove ${rowGroupLabelPrefix.toLowerCase()} in row ${rowNumber}`;
106+
};
107+
108+
const departmentOptions = [
109+
{ label: 'Choose a department', value: '', disabled: true },
110+
{ label: 'Engineering', value: 'engineering' },
111+
{ label: 'Marketing', value: 'marketing' },
112+
{ label: 'Sales', value: 'sales' },
113+
{ label: 'Human Resources', value: 'hr' },
114+
{ label: 'Finance', value: 'finance' }
115+
];
116+
117+
const roleOptions = [
118+
{ label: 'Choose a role', value: '', disabled: true },
119+
{ label: 'Manager', value: 'manager' },
120+
{ label: 'Senior', value: 'senior' },
121+
{ label: 'Junior', value: 'junior' },
122+
{ label: 'Intern', value: 'intern' },
123+
{ label: 'Contractor', value: 'contractor' }
124+
];
125+
126+
return (
127+
<Form>
128+
<FieldBuilder
129+
isRequired
130+
firstColumnLabel="Department"
131+
secondColumnLabel="Role"
132+
rowCount={teamMembers.length}
133+
onAddRow={handleAddTeamMember}
134+
onRemoveRow={handleRemoveTeamMember}
135+
onAddRowAnnouncement={customAddAnnouncement}
136+
onRemoveRowAnnouncement={customRemoveAnnouncement}
137+
removeButtonAriaLabel={customRemoveAriaLabel}
138+
rowGroupLabelPrefix="Team member"
139+
addButtonContent="Add team member"
140+
>
141+
{({ focusRef, firstColumnAriaLabel, secondColumnAriaLabel }, index) => [
142+
<Select
143+
key="department"
144+
id={`department-select-${index}`}
145+
isOpen={departmentOpenStates[index] || false}
146+
selected={teamMembers[index]?.department || ''}
147+
onSelect={(event, value) => handleDepartmentSelect(index, event, value)}
148+
onOpenChange={(isOpen) => {
149+
const newOpenStates = [ ...departmentOpenStates ];
150+
newOpenStates[index] = isOpen;
151+
setDepartmentOpenStates(newOpenStates);
152+
}}
153+
toggle={(toggleRef: React.Ref<MenuToggleElement>) => {
154+
// Compute extra context for aria-label
155+
const selectedDepartment = teamMembers[index]?.department;
156+
const departmentLabel = selectedDepartment
157+
? departmentOptions.find(opt => opt.value === selectedDepartment)?.label
158+
: 'choose a department';
159+
const ariaLabel = `${firstColumnAriaLabel}, ${departmentLabel}`;
160+
return (
161+
<MenuToggle
162+
ref={(element) => {
163+
// Handle both the toggle ref and focus ref
164+
if (typeof toggleRef === 'function') {
165+
toggleRef(element);
166+
} else if (toggleRef && 'current' in toggleRef && toggleRef.current !== element) {
167+
(toggleRef as React.MutableRefObject<MenuToggleElement | null>).current = element;
168+
}
169+
focusRef(element);
170+
}}
171+
onClick={() => handleDepartmentToggle(index)}
172+
isExpanded={departmentOpenStates[index] || false}
173+
aria-label={ariaLabel}
174+
style={{ width: '100%' }}
175+
>
176+
{selectedDepartment
177+
? departmentOptions.find(opt => opt.value === selectedDepartment)?.label || 'Choose a department'
178+
: 'Choose a department'}
179+
</MenuToggle>
180+
);
181+
}}
182+
shouldFocusToggleOnSelect
183+
>
184+
<SelectList>
185+
{departmentOptions.map((option, optionIndex) => (
186+
<SelectOption
187+
key={optionIndex}
188+
value={option.value}
189+
isDisabled={option.disabled}
190+
>
191+
{option.label}
192+
</SelectOption>
193+
))}
194+
</SelectList>
195+
</Select>,
196+
<Select
197+
key="role"
198+
id={`role-select-${index}`}
199+
isOpen={roleOpenStates[index] || false}
200+
selected={teamMembers[index]?.role || ''}
201+
onSelect={(event, value) => handleRoleSelect(index, event, value)}
202+
onOpenChange={(isOpen) => {
203+
const newOpenStates = [ ...roleOpenStates ];
204+
newOpenStates[index] = isOpen;
205+
setRoleOpenStates(newOpenStates);
206+
}}
207+
toggle={(toggleRef: React.Ref<MenuToggleElement>) => (
208+
<MenuToggle
209+
ref={toggleRef}
210+
onClick={() => handleRoleToggle(index)}
211+
isExpanded={roleOpenStates[index] || false}
212+
aria-label={`${secondColumnAriaLabel}, ${
213+
teamMembers[index]?.role
214+
? roleOptions.find(opt => opt.value === teamMembers[index]?.role)?.label
215+
: 'choose a role'
216+
}`}
217+
style={{ width: '100%' }}
218+
>
219+
{teamMembers[index]?.role
220+
? roleOptions.find(opt => opt.value === teamMembers[index]?.role)?.label || 'Choose a role'
221+
: 'Choose a role'}
222+
</MenuToggle>
223+
)}
224+
shouldFocusToggleOnSelect
225+
>
226+
<SelectList>
227+
{roleOptions.map((option, optionIndex) => (
228+
<SelectOption
229+
key={optionIndex}
230+
value={option.value}
231+
isDisabled={option.disabled}
232+
>
233+
{option.label}
234+
</SelectOption>
235+
))}
236+
</SelectList>
237+
</Select>
238+
]}
239+
</FieldBuilder>
240+
</Form>
241+
);
242+
};
243+
244+
export default FieldBuilderSelectExample;

0 commit comments

Comments
 (0)