Skip to content

Commit 6f2949b

Browse files
authored
Add tabAttributes prop to FAQGroup component (#659)
* add tabAttributes prop to FAQGroup * add tests for tabAttributes prop * remove unnecessary awaits * add documentation for tabAttributes * tidy up FAQ component documentation imports * add changeset * improve FAQGroup test suite * add tabAttributes example to documentation
1 parent 7ae1a4b commit 6f2949b

File tree

4 files changed

+136
-70
lines changed

4 files changed

+136
-70
lines changed

.changeset/perfect-wasps-speak.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@primer/react-brand': patch
3+
---
4+
5+
Added `tabAttributes` prop to `FAQGroup` component. This prop is used to set arbitrary attributes on the tabs rendered by the `FAQGroup` component.
6+
7+
For example, the below code will add `data-analytics="tab-0"` to the first tab and `data-analytics="tab-1"` to the second tab.
8+
9+
```tsx
10+
<FAQGroup
11+
tabAttributes={(children, index) => ({
12+
'data-analytics': `tab-${index}`,
13+
})}
14+
>
15+
<FAQGroup.Heading>Frequently asked questions</FAQGroup.Heading>
16+
<FAQ>
17+
<FAQ.Heading>Using GitHub Enterprise</FAQ.Heading>
18+
<FAQ.Item>...</FAQ.Item>
19+
<FAQ.Item>...</FAQ.Item>
20+
<FAQ.Item>...</FAQ.Item>
21+
</FAQ>
22+
23+
<FAQ>
24+
<FAQ.Heading>About GitHub Enterprise</FAQ.Heading>
25+
<FAQ.Item>...</FAQ.Item>
26+
<FAQ.Item>...</FAQ.Item>
27+
<FAQ.Item>...</FAQ.Item>
28+
</FAQ>
29+
</FAQGroup>
30+
```

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

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,11 @@ description: Use the FAQ component to display content in a Q&A format.
88
---
99

1010
import ComponentLayout from '../../../src/layouts/component-layout'
11-
export default ComponentLayout
1211

13-
import InlineCode from '@primer/gatsby-theme-doctocat/src/components/inline-code'
1412
import {HeadingTags} from '@primer/react-brand'
15-
import {Box as Container} from '@primer/react'
16-
import {PropTableValues} from '../../../src/components'
1713
import {Label} from '@primer/react'
14+
import {PropTableValues} from '../../../src/components'
15+
export default ComponentLayout
1816

1917
```js
2018
import {FAQ, FAQGroup} from '@primer/react-brand'
@@ -114,8 +112,14 @@ import {FAQ, FAQGroup} from '@primer/react-brand'
114112
115113
Use `FAQGroup` to display multiple `FAQ` components together.
116114
115+
Use `tabAttributes` to add arbitrary attributes to the tabs. This can be useful for tracking analytics or adding custom attributes.
116+
117117
```jsx live
118-
<FAQGroup>
118+
<FAQGroup
119+
tabAttributes={(children, i) => ({
120+
'data-analytics': `faq-tab-${i}`,
121+
})}
122+
>
119123
<FAQGroup.Heading>
120124
Frequently asked <br /> questions
121125
</FAQGroup.Heading>
@@ -433,12 +437,13 @@ render(<App />)
433437
434438
### FAQGroup
435439
436-
| Name | Type | Default | Description |
437-
| :---------- | :----------------------------------------------------------------------------------------------------------------------------- | :-----: | :--------------------------------------- |
438-
| `className` | `string` | | Sets a custom class |
439-
| `id` | `string` | | Sets a custom id |
440-
| `ref` | `React.RefObject` | | Forward a Ref to the underlying DOM node |
441-
| `children` | <PropTableValues values={[<a href="#faq-required">FAQ</a>, <a href="#faqgroup-heading">FAQGroup.Heading</a>]} addLineBreaks /> | | Root element for the FAQGroup component. |
440+
| Name | Type | Default | Description |
441+
| :-------------- | :----------------------------------------------------------------------------------------------------------------------------- | :-----: | :---------------------------------------------------------------------------- |
442+
| `className` | `string` | | Sets a custom class |
443+
| `id` | `string` | | Sets a custom id |
444+
| `ref` | `React.RefObject` | | Forward a Ref to the underlying DOM node |
445+
| `children` | <PropTableValues values={[<a href="#faq-required">FAQ</a>, <a href="#faqgroup-heading">FAQGroup.Heading</a>]} addLineBreaks /> | | Root element for the FAQGroup component. |
446+
| `tabAttributes` | `(children: ReactElement, index: number) => Record<string, unknown>` | | Spreads the returned attributes onto the tab that's rendered by the FAQGroup. |
442447
443448
### FAQGroup.Heading
444449

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

Lines changed: 80 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'
33
import '@testing-library/jest-dom'
44
import {axe, toHaveNoViolations} from 'jest-axe'
55

6-
import {FAQ, FAQGroup} from './'
6+
import {FAQ, FAQGroup, type FAQGroupProps} from './'
77

88
expect.extend(toHaveNoViolations)
99

@@ -43,10 +43,27 @@ describe('FAQGroup', () => {
4343
},
4444
],
4545
},
46+
{
47+
heading: 'mock heading 3',
48+
faqs: [
49+
{
50+
question: 'mock question 7',
51+
answer: 'mock answer 7',
52+
},
53+
{
54+
question: 'mock question 8',
55+
answer: 'mock answer 8',
56+
},
57+
{
58+
question: 'mock question 9',
59+
answer: 'mock answer 9',
60+
},
61+
],
62+
},
4663
]
4764

48-
const Component = () => (
49-
<FAQGroup data-testid="root">
65+
const Component = (props: FAQGroupProps) => (
66+
<FAQGroup data-testid="root" {...props}>
5067
<FAQGroup.Heading>Frequently asked questions</FAQGroup.Heading>
5168
{testData.map((group, index) => (
5269
<FAQ key={index}>
@@ -66,76 +83,84 @@ describe('FAQGroup', () => {
6683

6784
afterEach(cleanup)
6885

69-
it('renders groups of FAQS into the document, showing the first group only', () => {
70-
const {getByTestId, getAllByText, getByText} = render(<Component />)
71-
72-
const rootEl = getByTestId('root')
73-
const mainHeading = getByText('Frequently asked questions')
74-
75-
const [, headingAsTab, headingAsSubHead] = getAllByText('mock heading 1')
86+
it('has no a11y violations', async () => {
87+
const {container} = render(<Component />)
88+
const results = await axe(container)
7689

77-
expect(rootEl).toBeInTheDocument()
78-
expect(mainHeading).toBeInTheDocument()
79-
expect(headingAsTab).toBeInTheDocument()
80-
expect(headingAsSubHead).toBeInTheDocument()
90+
expect(results).toHaveNoViolations()
8191
})
8292

8393
it('selects the first tab by default', () => {
84-
const {getByTestId} = render(<Component />)
94+
const {getByRole, queryByRole} = render(<Component />)
8595

86-
const buttonOneEl = getByTestId('FAQGroup-tab-1')
87-
const panelOne = getByTestId('FAQGroup-tab-panel-1')
88-
const panelTwo = getByTestId('FAQGroup-tab-panel-2')
96+
expect(getByRole('tab', {name: 'mock heading 1'})).toHaveAttribute('aria-selected', 'true')
97+
expect(getByRole('tab', {name: 'mock heading 2'})).toHaveAttribute('aria-selected', 'false')
98+
expect(getByRole('tab', {name: 'mock heading 3'})).toHaveAttribute('aria-selected', 'false')
8999

90-
expect(buttonOneEl).toBeInTheDocument()
91-
expect(buttonOneEl).toHaveAttribute('aria-selected', 'true')
92-
expect(panelOne).toBeVisible()
93-
expect(panelTwo).not.toBeVisible()
100+
expect(getByRole('tabpanel', {name: 'mock heading 1'})).toBeVisible()
101+
expect(queryByRole('tabpanel', {name: 'mock heading 2'})).not.toBeInTheDocument()
102+
expect(queryByRole('tabpanel', {name: 'mock heading 3'})).not.toBeInTheDocument()
94103
})
95104

96-
it('selects the first tab by default', async () => {
97-
const {getByTestId} = render(<Component />)
105+
it('changes selected tab on ArrowUp and ArrowDown key presses', () => {
106+
const {getByRole, queryByRole} = render(<Component />)
98107

99-
const buttonOneEl = getByTestId('FAQGroup-tab-1')
100-
const buttonTwoEl = getByTestId('FAQGroup-tab-2')
101-
const panelOne = getByTestId('FAQGroup-tab-panel-1')
102-
const panelTwo = getByTestId('FAQGroup-tab-panel-2')
108+
const headings = ['mock heading 1', 'mock heading 2', 'mock heading 3']
109+
const assertSelectedTabIndex = (selectedTabIndex: number) => {
110+
for (let i = 0; i < headings.length; i++) {
111+
const heading = headings[i]
103112

104-
await userEvent.click(buttonTwoEl)
113+
if (i === selectedTabIndex) {
114+
expect(getByRole('tab', {name: heading})).toHaveAttribute('aria-selected', 'true')
115+
expect(getByRole('tabpanel', {name: heading})).toBeVisible()
116+
} else {
117+
expect(getByRole('tab', {name: heading})).toHaveAttribute('aria-selected', 'false')
118+
expect(queryByRole('tabpanel', {name: heading})).not.toBeInTheDocument()
119+
}
120+
}
121+
}
105122

106-
expect(buttonOneEl).toHaveAttribute('aria-selected', 'false')
107-
expect(buttonTwoEl).toHaveAttribute('aria-selected', 'true')
108-
expect(panelTwo).toBeVisible()
109-
expect(panelOne).not.toBeVisible()
110-
})
123+
assertSelectedTabIndex(0)
111124

112-
it('has no a11y violations', async () => {
113-
const {container} = render(<Component />)
114-
const results = await axe(container)
125+
userEvent.type(getByRole('tab', {name: 'mock heading 1'}), '{arrowdown}')
126+
assertSelectedTabIndex(1)
115127

116-
expect(results).toHaveNoViolations()
128+
userEvent.type(getByRole('tab', {name: 'mock heading 2'}), '{arrowdown}')
129+
assertSelectedTabIndex(2)
130+
131+
userEvent.type(getByRole('tab', {name: 'mock heading 3'}), '{arrowup}')
132+
assertSelectedTabIndex(1)
133+
134+
userEvent.type(getByRole('tab', {name: 'mock heading 2'}), '{arrowup}')
135+
assertSelectedTabIndex(0)
136+
137+
userEvent.type(getByRole('tab', {name: 'mock heading 1'}), '{arrowup}')
138+
assertSelectedTabIndex(2)
117139
})
118140

119-
it('changes selected tab on ArrowUp and ArrowDown key presses', async () => {
120-
const {getByTestId} = render(<Component />)
121-
const firstTabButton = getByTestId('FAQGroup-tab-1')
122-
const secondTabButton = getByTestId('FAQGroup-tab-2')
123-
const lastTabButton = getByTestId(`FAQGroup-tab-2`)
141+
it('calls `tabAttributes` with the correct arguments', () => {
142+
const mockTabAttributes = jest.fn((_, i) => ({
143+
'data-tab-index': i,
144+
}))
145+
146+
render(<Component tabAttributes={mockTabAttributes} />)
147+
const mockCalls = mockTabAttributes.mock.calls
124148

125-
await userEvent.type(firstTabButton, '{arrowdown}')
126-
expect(secondTabButton).toHaveAttribute('aria-selected', 'true')
127-
expect(getByTestId('FAQGroup-tab-panel-2')).not.toHaveAttribute('hidden')
149+
expect(mockTabAttributes).toHaveBeenCalledTimes(3)
150+
expect(mockCalls[0]).toEqual(['mock heading 1', 0])
151+
expect(mockCalls[1]).toEqual(['mock heading 2', 1])
152+
expect(mockCalls[2]).toEqual(['mock heading 3', 2])
153+
})
128154

129-
await userEvent.type(secondTabButton, '{arrowup}')
130-
expect(firstTabButton).toHaveAttribute('aria-selected', 'true')
131-
expect(firstTabButton).not.toHaveAttribute('hidden')
155+
it('adds props to the tabs when `tabAttributes` is provided', () => {
156+
const mockTabAttributes = jest.fn(children => ({
157+
'data-tab-heading': children,
158+
}))
132159

133-
await userEvent.type(firstTabButton, '{arrowup}')
134-
expect(lastTabButton).toHaveAttribute('aria-selected', 'true')
135-
expect(lastTabButton).not.toHaveAttribute('hidden')
160+
const {getByRole} = render(<Component tabAttributes={mockTabAttributes} />)
136161

137-
await userEvent.type(lastTabButton, '{arrowdown}')
138-
expect(firstTabButton).toHaveAttribute('aria-selected', 'true')
139-
expect(firstTabButton).not.toHaveAttribute('hidden')
162+
expect(getByRole('tab', {name: 'mock heading 1'})).toHaveAttribute('data-tab-heading', 'mock heading 1')
163+
expect(getByRole('tab', {name: 'mock heading 2'})).toHaveAttribute('data-tab-heading', 'mock heading 2')
164+
expect(getByRole('tab', {name: 'mock heading 3'})).toHaveAttribute('data-tab-heading', 'mock heading 3')
140165
})
141166
})

packages/react/src/FAQ/FAQGroup.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from 'react'
1+
import React, {type ReactElement} from 'react'
22
import {useId} from '@reach/auto-id'
33
import clsx from 'clsx'
44

@@ -25,12 +25,13 @@ function _Heading({children, className, as = 'h3', ...rest}: FAQSubheadingProps)
2525
)
2626
}
2727

28-
type FAQGroupProps = React.PropsWithChildren<{
28+
export type FAQGroupProps = React.PropsWithChildren<{
2929
id?: string
3030
defaultSelectedIndex?: number
31+
tabAttributes?: (children: ReactElement, index: number) => Record<string, unknown>
3132
}>
3233

33-
function _FAQGroup({children, id, defaultSelectedIndex = 0, ...rest}: FAQGroupProps) {
34+
function _FAQGroup({children, id, defaultSelectedIndex = 0, tabAttributes, ...rest}: FAQGroupProps) {
3435
const [selectedIndex, setSelectedIndex] = React.useState(defaultSelectedIndex)
3536
const [hasInteracted, setHasInteracted] = React.useState(false)
3637
const instanceId = useId(id)
@@ -73,8 +74,13 @@ function _FAQGroup({children, id, defaultSelectedIndex = 0, ...rest}: FAQGroupPr
7374
child => React.isValidElement(child) && child.type === FAQ.Heading,
7475
)
7576

77+
const tabContents = React.isValidElement(GroupHeadingChild) && GroupHeadingChild.props.children
78+
79+
const providedTabAttributes = tabAttributes?.(tabContents, index)
80+
7681
return (
7782
<Button
83+
{...providedTabAttributes}
7884
variant="subtle"
7985
hasArrow={false}
8086
as="button"
@@ -91,7 +97,7 @@ function _FAQGroup({children, id, defaultSelectedIndex = 0, ...rest}: FAQGroupPr
9197
tabIndex={selectedIndex !== index ? -1 : undefined}
9298
ref={selectedIndex === index ? selectedTabRef : undefined}
9399
>
94-
{React.isValidElement(GroupHeadingChild) && GroupHeadingChild.props.children}
100+
{tabContents}
95101
</Button>
96102
)
97103
}

0 commit comments

Comments
 (0)