Skip to content

Commit fe19fe9

Browse files
authored
Update heading levels in the FAQ component (#1053)
* add tests asserting desired heading structure * update default FAQ.Subheading heading level to h4 * render FAQ.Question as a h5 if there is a FAQ.Subheading present * update FAQGroup to respect all provided FAQ.Heading props, not just children * render FAQ.Question as a h5 if nested inside a FAQ.Group * fix incorrect FAQ.Question heading levels FeaturePreviewLevelTwo playground * add changeset * update default FAQ.Subheading level in docs * update changeset * ensure FAQ.Question props are respected when there is a FAQ.Subheading * extract mockSubheading out to own variable in tests * handle FAQ.Items being wrapped in fragments * handle the case where FAQ.Items are wrapped in a fragment and an array
1 parent 095f1b7 commit fe19fe9

File tree

8 files changed

+219
-49
lines changed

8 files changed

+219
-49
lines changed

.changeset/purple-plants-search.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@primer/react-brand': patch
3+
---
4+
5+
Improved default heading levels in the `FAQ` component.
6+
7+
- Updated `FAQ.Subheading` heading level from `h3` to `h4`.
8+
- `FAQ.Question` now renders as a `h5` if there is a `FAQ.Subheading` present, or if rendered inside a `FAQGroup`.
9+
- `FAQGroup` now respects all provided `FAQ.Heading` props, not just children.

apps/docs/content/components/FAQ/react.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,7 @@ render(<App />)
409409
| `children` | `string` | | Sub-heading text |
410410
| `className` | `string` | | Sub-heading custom class |
411411
| `id` | `string` | | Sets a custom id |
412-
| `as` | <PropTableValues values={HeadingTags.slice(1)} addLineBreaks /> | `'h3'` | Applies the underlying HTML element |
412+
| `as` | <PropTableValues values={HeadingTags.slice(1)} addLineBreaks /> | `'h4'` | Applies the underlying HTML element |
413413
| `ref` | `React.RefObject` | | Forward a Ref to the underlying DOM node |
414414
415415
<h3 id="faq-item">FAQ.Item</h3>

apps/next-docs/content/components/FAQ/react.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,7 @@ render(<App />)
386386
| `children` | `string` | | Sub-heading text |
387387
| `className` | `string` | | Sub-heading custom class |
388388
| `id` | `string` | | Sets a custom id |
389-
| `as` | <FAQSubheadingAsProp /> | `'h3'` | Applies the underlying HTML element |
389+
| `as` | <FAQSubheadingAsProp /> | `'h4'` | Applies the underlying HTML element |
390390
| `ref` | `React.RefObject` | | Forward a Ref to the underlying DOM node |
391391
392392
<h3 id="faq-item">FAQ.Item</h3>

packages/react/src/FAQ/FAQ.test.tsx

Lines changed: 95 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ describe('FAQ', () => {
1010
const questionRoot = 'question-root'
1111

1212
const mockHeading = 'This is a mock heading'
13+
const mockSubheading = 'this is a mock subheading'
1314
const mockQuestion = 'What is a mock question?'
1415
const mockFAQAnswer = 'mock answer'
1516

@@ -137,7 +138,6 @@ describe('FAQ', () => {
137138
})
138139

139140
it('can render groups of FAQs using an optional sub-heading', () => {
140-
const mockSubheading = 'this is a mock subheading'
141141
const invalidChild = <div>This is an invalid child</div>
142142
const {getByRole} = render(
143143
<FAQ>
@@ -153,13 +153,12 @@ describe('FAQ', () => {
153153
</FAQ>,
154154
)
155155

156-
const subheadingEl = getByRole('heading', {level: 3, name: mockSubheading})
156+
const subheadingEl = getByRole('heading', {level: 4, name: mockSubheading})
157157

158158
expect(subheadingEl).toBeInTheDocument()
159159
})
160160

161-
it('renders alternative headling levels', () => {
162-
const mockSubheading = 'this is a mock subheading'
161+
it('renders alternative heading levels', () => {
163162
const {getByRole} = render(
164163
<FAQ>
165164
<FAQ.Heading as="h3">{mockHeading}</FAQ.Heading>
@@ -181,4 +180,96 @@ describe('FAQ', () => {
181180
expect(subheadingEl).toBeInTheDocument()
182181
expect(questionheadingEl).toBeInTheDocument()
183182
})
183+
184+
it('renders the heading as a h3 and the question as a h4 by default', () => {
185+
const {getByRole} = render(
186+
<FAQ>
187+
<FAQ.Heading>{mockHeading}</FAQ.Heading>
188+
<FAQ.Item>
189+
<FAQ.Question>{mockQuestion}</FAQ.Question>
190+
<FAQ.Answer>
191+
<p>{mockFAQAnswer}</p>
192+
</FAQ.Answer>
193+
</FAQ.Item>
194+
</FAQ>,
195+
)
196+
197+
const mainheadingEl = getByRole('heading', {level: 3, name: mockHeading})
198+
const questionheadingEl = getByRole('heading', {level: 4, name: mockQuestion})
199+
200+
expect(mainheadingEl).toBeInTheDocument()
201+
expect(questionheadingEl).toBeInTheDocument()
202+
})
203+
204+
it('renders the heading as a h3, the subheading as a h4, and the question as a h5 when there is a subheading present', () => {
205+
const {getByRole} = render(
206+
<FAQ>
207+
<FAQ.Heading>{mockHeading}</FAQ.Heading>
208+
<FAQ.Subheading>{mockSubheading}</FAQ.Subheading>
209+
<FAQ.Item>
210+
<FAQ.Question>{mockQuestion}</FAQ.Question>
211+
<FAQ.Answer>
212+
<p>{mockFAQAnswer}</p>
213+
</FAQ.Answer>
214+
</FAQ.Item>
215+
</FAQ>,
216+
)
217+
218+
const mainheadingEl = getByRole('heading', {level: 3, name: mockHeading})
219+
const subheadingEl = getByRole('heading', {level: 4, name: mockSubheading})
220+
const questionheadingEl = getByRole('heading', {level: 5, name: mockQuestion})
221+
222+
expect(mainheadingEl).toBeInTheDocument()
223+
expect(subheadingEl).toBeInTheDocument()
224+
expect(questionheadingEl).toBeInTheDocument()
225+
})
226+
227+
it('renders the heading as a h3, the subheading as a h4, and the question as a h5 when there is a subheading present, and the FAQ.Item is wrapped in a Fragment', () => {
228+
const {getByRole} = render(
229+
<FAQ>
230+
<FAQ.Heading>{mockHeading}</FAQ.Heading>
231+
<FAQ.Subheading>{mockSubheading}</FAQ.Subheading>
232+
<>
233+
<FAQ.Item key="item-1">
234+
<FAQ.Question>{mockQuestion}</FAQ.Question>
235+
<FAQ.Answer>
236+
<p>{mockFAQAnswer}</p>
237+
</FAQ.Answer>
238+
</FAQ.Item>
239+
</>
240+
</FAQ>,
241+
)
242+
243+
const mainheadingEl = getByRole('heading', {level: 3, name: mockHeading})
244+
const subheadingEl = getByRole('heading', {level: 4, name: mockSubheading})
245+
const questionheadingEl = getByRole('heading', {level: 5, name: mockQuestion})
246+
247+
expect(mainheadingEl).toBeInTheDocument()
248+
expect(subheadingEl).toBeInTheDocument()
249+
expect(questionheadingEl).toBeInTheDocument()
250+
})
251+
252+
it('renders the heading as a h3, the subheading as a h4, and the question as a h5 when there is a subheading present, FAQ.Items are wrapped in a Fragment, and all FAQ.Items are wrapped in an array', () => {
253+
const {getByRole} = render(
254+
<FAQ>
255+
<FAQ.Heading>{mockHeading}</FAQ.Heading>
256+
<FAQ.Subheading>{mockSubheading}</FAQ.Subheading>
257+
<>
258+
{[1, 2].map(item => {
259+
return (
260+
<FAQ.Item key={`item-${item}`}>
261+
<FAQ.Question>Question {item}</FAQ.Question>
262+
<FAQ.Answer>Answer {item}</FAQ.Answer>
263+
</FAQ.Item>
264+
)
265+
})}
266+
</>
267+
</FAQ>,
268+
)
269+
270+
expect(getByRole('heading', {level: 3, name: mockHeading})).toBeInTheDocument()
271+
expect(getByRole('heading', {level: 4, name: mockSubheading})).toBeInTheDocument()
272+
expect(getByRole('heading', {level: 5, name: 'Question 1'})).toBeInTheDocument()
273+
expect(getByRole('heading', {level: 5, name: 'Question 2'})).toBeInTheDocument()
274+
})
184275
})

packages/react/src/FAQ/FAQ.tsx

Lines changed: 50 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,30 @@ export type FAQRootProps = PropsWithChildren<BaseProps<HTMLElement>> & React.HTM
2222
const FAQRoot = forwardRef<HTMLElement, FAQRootProps>(({children, style, animate, className, ...rest}, ref) => {
2323
const {classes: animationClasses, styles: animationInlineStyles} = useAnimation(animate)
2424

25-
const filteredChildren = React.Children.toArray(children).filter(child => {
26-
if (React.isValidElement(child) && typeof child.type !== 'string') {
27-
if (
28-
isFragment(child) ||
29-
(child as React.ReactElement).type === FAQHeading ||
30-
(child as React.ReactElement).type === FAQSubheading ||
31-
(child as React.ReactElement).type === AccordionRoot
32-
) {
33-
return true
25+
const filteredChildren = React.Children.toArray(children)
26+
.filter(child => {
27+
if (React.isValidElement(child) && typeof child.type !== 'string') {
28+
if (
29+
isFragment(child) ||
30+
(child as React.ReactElement).type === FAQHeading ||
31+
(child as React.ReactElement).type === FAQSubheading ||
32+
(child as React.ReactElement).type === AccordionRoot
33+
) {
34+
return true
35+
}
3436
}
35-
}
36-
return false
37-
})
37+
return false
38+
})
39+
.map(childMaybeFragment => {
40+
/**
41+
* If children are wrapped in a Fragment, extract the children.
42+
* Children may also be an array of FAQ.Item components, so we call
43+
* `toArray` to ensure we always have an array.
44+
*/
45+
return React.Children.toArray(
46+
isFragment(childMaybeFragment) ? childMaybeFragment.props.children : childMaybeFragment,
47+
)
48+
})
3849

3950
const hasSubheading = React.Children.toArray(children).some(
4051
child => React.isValidElement(child) && typeof child.type !== 'string' && child.type === FAQSubheading,
@@ -47,17 +58,39 @@ const FAQRoot = forwardRef<HTMLElement, FAQRootProps>(({children, style, animate
4758
style={{...animationInlineStyles, ...style}}
4859
{...rest}
4960
>
50-
{React.Children.toArray(filteredChildren).map(child => {
51-
if (React.isValidElement(child) && typeof child.type !== 'string') {
61+
{filteredChildren.map(childArr => {
62+
return childArr.map(child => {
63+
if (!React.isValidElement(child) || typeof child.type === 'string') {
64+
return child
65+
}
66+
5267
if (child.type === FAQHeading) {
5368
return React.cloneElement(child as React.ReactElement, {
5469
align: hasSubheading ? 'start' : child.props.align,
5570
size: hasSubheading ? '3' : child.props.size,
5671
weight: hasSubheading ? 'semibold' : child.props.weight,
5772
})
5873
}
59-
}
60-
return child
74+
75+
// If there is a subheading, ensure that the FAQ.Question is rendered as a h5
76+
if (hasSubheading && child.type === FAQ.Item) {
77+
const grandChildren = React.Children.map(child.props.children, grandChild => {
78+
if (grandChild.type === FAQ.Question) {
79+
return React.cloneElement(grandChild as React.ReactElement, {
80+
as: 'h5',
81+
...grandChild.props,
82+
})
83+
}
84+
return grandChild
85+
})
86+
87+
return React.cloneElement(child as React.ReactElement, {
88+
children: grandChildren,
89+
})
90+
}
91+
92+
return child
93+
})
6194
})}
6295
</section>
6396
)
@@ -95,7 +128,7 @@ export type FAQSubheadingProps = BaseProps<HTMLHeadingElement> & {
95128
function FAQSubheading({
96129
children,
97130
className,
98-
as = 'h3',
131+
as = 'h4',
99132
size = 'subhead-large',
100133
weight = 'medium',
101134
...rest

packages/react/src/FAQ/FAQGroup.test.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,4 +163,23 @@ describe('FAQGroup', () => {
163163
expect(getByRole('tab', {name: 'mock heading 2'})).toHaveAttribute('data-tab-heading', 'mock heading 2')
164164
expect(getByRole('tab', {name: 'mock heading 3'})).toHaveAttribute('data-tab-heading', 'mock heading 3')
165165
})
166+
167+
it('renders the FAQGroup.Heading as a h3, the FAQ.Heading as a h4, and the question as a h5', () => {
168+
const {getByRole, getAllByRole} = render(<Component />)
169+
170+
const faqGroupHeading = getByRole('heading', {level: 3, name: 'Frequently asked questions'})
171+
const questionHeading = getByRole('heading', {level: 5, name: 'mock question 1'})
172+
173+
expect(faqGroupHeading).toBeInTheDocument()
174+
expect(questionHeading).toBeInTheDocument()
175+
176+
/**
177+
* We use getAllByRole here as we render two h4 elements with the same text content and hide one with media queries.
178+
* Since our test suite doesn't render styles, Testing Library finds both of them, so we check both even though one is hidden.
179+
*/
180+
const faqHeadings = getAllByRole('heading', {level: 4, name: 'mock heading 1'})
181+
for (const faqHeading of faqHeadings) {
182+
expect(faqHeading).toBeInTheDocument()
183+
}
184+
})
166185
})

packages/react/src/FAQ/FAQGroup.tsx

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -106,13 +106,30 @@ function _FAQGroup({children, id, defaultSelectedIndex = 0, tabAttributes, ...re
106106

107107
const TabPanels = React.Children.map(faqChildren, (faqChild, index) => {
108108
if (React.isValidElement<FAQRootProps>(faqChild) && faqChild.props.children) {
109-
const FAQItemChild = React.Children.map(faqChild.props.children, child =>
110-
React.isValidElement(child) && child.type !== FAQ.Heading ? child : null,
111-
)
109+
const FAQItemChild = React.Children.map(faqChild.props.children, child => {
110+
if (!React.isValidElement(child) || child.type === FAQ.Heading) {
111+
return null
112+
}
113+
114+
// Make sure that the FAQ.Question is rendered as a h5
115+
const grandChildren = React.Children.map(child.props.children, grandChild => {
116+
if (grandChild.type === FAQ.Question) {
117+
return React.cloneElement(grandChild as React.ReactElement, {
118+
as: 'h5',
119+
...grandChild.props,
120+
})
121+
}
122+
return grandChild
123+
})
112124

113-
const FAQItemHeadingText = React.Children.map(faqChild.props.children, child =>
114-
React.isValidElement(child) && child.type === FAQ.Heading ? child.props.children : null,
115-
)
125+
return React.cloneElement(child as React.ReactElement, {
126+
children: grandChildren,
127+
})
128+
})
129+
130+
const FAQItemHeading = React.Children.toArray(faqChild.props.children).find(
131+
child => React.isValidElement(child) && child.type === FAQ.Heading,
132+
) as ReactElement | undefined
116133

117134
return (
118135
<div
@@ -123,13 +140,12 @@ function _FAQGroup({children, id, defaultSelectedIndex = 0, tabAttributes, ...re
123140
key={index}
124141
data-testid={`FAQGroup-tab-panel-${index + 1}`}
125142
>
126-
{FAQItemHeadingText && (
143+
{FAQItemHeading && (
127144
<FAQ.Subheading
145+
{...FAQItemHeading.props}
128146
data-testid={`FAQGroup-tab-panel-heading-${index + 1}`}
129-
className={clsx(styles['FAQGroup__panel-subHeading'])}
130-
>
131-
{FAQItemHeadingText}
132-
</FAQ.Subheading>
147+
className={clsx(styles['FAQGroup__panel-subHeading'], FAQItemHeading.props.className)}
148+
/>
133149
)}
134150
{FAQItemChild}
135151
</div>

0 commit comments

Comments
 (0)