Skip to content

Commit 3512d15

Browse files
authored
Enforce order of child components in Card (#1048)
* simplify component type checking * add fragment support and explicit child ordering * add ImageAndLabelWithFragment story * add test asserting dom order * fix failing tests * add changeset * github-actions[bot] Regenerated snapshots --------- Co-authored-by: joshfarrant <[email protected]>
1 parent fe19fe9 commit 3512d15

File tree

8 files changed

+123
-43
lines changed

8 files changed

+123
-43
lines changed

.changeset/cyan-files-work.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react-brand': patch
3+
---
4+
5+
Children passed to `Card` component now appear in the DOM in a predefined order to improve the experience of screen reader users.

packages/react/src/Card/Card.features.stories.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,21 @@ export const IconAndLabel: StoryFn<typeof Card> = () => {
226226
)
227227
}
228228

229+
export const IconAndLabelWithFragment: StoryFn<typeof Card> = () => {
230+
return (
231+
<Card href="https://github.com">
232+
<>
233+
<Card.Heading>Code search & code view</Card.Heading>
234+
<Card.Label color="blue-purple">Beta</Card.Label>
235+
<Card.Icon icon={ZapIcon} color="purple" hasBackground />
236+
<Card.Description>
237+
Enables you to rapidly search, navigate, and understand code, right from GitHub.com.
238+
</Card.Description>
239+
</>
240+
</Card>
241+
)
242+
}
243+
229244
export const Image: StoryFn<typeof Card> = () => {
230245
return (
231246
<Stack direction="horizontal">

packages/react/src/Card/Card.test.tsx

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ describe('Card', () => {
3131
const mockLabel = 'This is a mock label'
3232
const mockHeading = 'This is a mock heading'
3333
const mockDescription = 'This is a mock description'
34+
const mockIcon = <GitMergeIcon aria-label="Git merge icon" />
3435

3536
it('no a11y violations', async () => {
3637
const {container} = render(
@@ -140,52 +141,50 @@ describe('Card', () => {
140141
})
141142

142143
it('renders the label correctly into the document', () => {
143-
const mockTestId = 'test'
144+
const mockTestId = 'label'
144145
const classToCheck = 'Card__label'
145146

146147
const {getByTestId} = render(
147-
<Card href={mockHref} data-testid={mockTestId}>
148-
<Card.Label>{mockLabel}</Card.Label>
148+
<Card href={mockHref}>
149+
<Card.Label data-testid={mockTestId}>{mockLabel}</Card.Label>
149150
<Card.Heading>{mockHeading}</Card.Heading>
150151
<Card.Description>{mockDescription}</Card.Description>
151152
</Card>,
152153
)
153154

154-
const cardEl = getByTestId(mockTestId).firstChild
155-
expect(cardEl).toHaveClass(classToCheck)
156-
expect(cardEl).toHaveTextContent(mockLabel)
155+
const labelEl = getByTestId(mockTestId)
156+
expect(labelEl).toHaveClass(classToCheck)
157+
expect(labelEl).toHaveTextContent(mockLabel)
157158
})
158159

159160
it('renders the icon correctly into the document', () => {
160-
const mockTestId = 'test'
161+
const mockTestId = 'icon'
161162
const classToCheck = 'Card__icon'
162163

163164
const {getByTestId} = render(
164-
<Card href={mockHref} data-testid={mockTestId}>
165-
<Card.Icon icon={GitMergeIcon} />
165+
<Card href={mockHref}>
166+
<Card.Icon icon={mockIcon} data-testid={mockTestId} />
166167
<Card.Heading>{mockHeading}</Card.Heading>
167168
<Card.Description>{mockDescription}</Card.Description>
168169
</Card>,
169170
)
170171

171-
const cardEl = getByTestId(mockTestId).firstChild
172-
expect(cardEl).toHaveClass(classToCheck)
172+
expect(getByTestId(mockTestId).parentElement).toHaveClass(classToCheck)
173173
})
174174

175175
it('renders the icon with background correctly into the document', () => {
176-
const mockTestId = 'test'
176+
const mockTestId = 'icon'
177177
const classToCheck = 'Icon--background'
178178

179179
const {getByTestId} = render(
180-
<Card href={mockHref} data-testid={mockTestId}>
181-
<Card.Icon hasBackground icon={GitMergeIcon} />
180+
<Card href={mockHref}>
181+
<Card.Icon hasBackground icon={mockIcon} data-testid={mockTestId} />
182182
<Card.Heading>{mockHeading}</Card.Heading>
183183
<Card.Description>{mockDescription}</Card.Description>
184184
</Card>,
185185
)
186186

187-
const cardEl = getByTestId(mockTestId).firstChild
188-
expect(cardEl).toHaveClass(classToCheck)
187+
expect(getByTestId(mockTestId).parentElement).toHaveClass(classToCheck)
189188
})
190189

191190
it('renders the image correctly into the document', () => {
@@ -246,4 +245,30 @@ describe('Card', () => {
246245

247246
expect(cardEl.parentElement).toHaveClass('Card--fullWidth')
248247
})
248+
249+
it('renders card contents in a logical order, regardless of the order they are passed in', () => {
250+
const {getByText, getByAltText, getByLabelText} = render(
251+
<Card href={mockHref}>
252+
<Card.Description>{mockDescription}</Card.Description>
253+
<Card.Label>{mockLabel}</Card.Label>
254+
<Card.Heading>{mockHeading}</Card.Heading>
255+
<Card.Image src="/brand/assets/placeholder.png" alt="placeholder" />
256+
<Card.Icon icon={mockIcon} />
257+
</Card>,
258+
)
259+
260+
const description = getByText(mockDescription)
261+
const label = getByText(mockLabel)
262+
const heading = getByText(mockHeading)
263+
const image = getByAltText('placeholder')
264+
const icon = getByLabelText('Git merge icon')
265+
266+
const isAfter = (a: Element, b: Element): boolean =>
267+
a.compareDocumentPosition(b) === Node.DOCUMENT_POSITION_FOLLOWING
268+
269+
expect(isAfter(heading, image)).toBe(true)
270+
expect(isAfter(image, icon)).toBe(true)
271+
expect(isAfter(icon, label)).toBe(true)
272+
expect(isAfter(label, description)).toBe(true)
273+
})
249274
})

packages/react/src/Card/Card.tsx

Lines changed: 44 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ const CardRoot = forwardRef<HTMLDivElement, CardProps>(
7676
onMouseLeave,
7777
onFocus,
7878
onBlur,
79-
children,
79+
children: childrenMaybeWrappedInFragment,
8080
className,
8181
ctaText = 'Learn more',
8282
disableAnimation = false,
@@ -92,25 +92,33 @@ const CardRoot = forwardRef<HTMLDivElement, CardProps>(
9292
const cardRef = useProvidedRefOrCreate(ref as RefObject<HTMLDivElement>)
9393
const {colorMode} = useTheme()
9494

95-
const filteredChildren = React.Children.toArray(children).filter(child => {
96-
if (React.isValidElement(child) && typeof child.type !== 'string') {
97-
if (
98-
isFragment(child) ||
99-
(child as React.ReactElement).type === CardImage ||
100-
(child as React.ReactElement).type === CardIcon ||
101-
(child as React.ReactElement).type === CardLabel ||
102-
(child as React.ReactElement).type === CardHeading ||
103-
(child as React.ReactElement).type === CardDescription
104-
) {
105-
return true
106-
}
95+
const children = isFragment(childrenMaybeWrappedInFragment)
96+
? childrenMaybeWrappedInFragment.props.children
97+
: childrenMaybeWrappedInFragment
98+
99+
const {cardImage, cardIcon, cardLabel, cardHeading, cardDescription} = React.Children.toArray(children).reduce<{
100+
cardHeading?: ReturnType<typeof CardHeading>
101+
cardImage?: ReturnType<typeof CardImage>
102+
cardIcon?: ReturnType<typeof CardIcon>
103+
cardLabel?: ReturnType<typeof CardLabel>
104+
cardDescription?: ReturnType<typeof CardDescription>
105+
}>((acc, child) => {
106+
if (isCardHeading(child)) {
107+
acc.cardHeading = React.cloneElement(child, {
108+
href,
109+
})
110+
} else if (isCardImage(child)) {
111+
acc.cardImage = child
112+
} else if (isCardIcon(child)) {
113+
acc.cardIcon = child
114+
} else if (isCardLabel(child)) {
115+
acc.cardLabel = child
116+
} else if (isCardDescription(child)) {
117+
acc.cardDescription = child
107118
}
108-
return false
109-
})
110119

111-
const hasIcon = React.Children.toArray(children).some(
112-
child => React.isValidElement(child) && typeof child.type !== 'string' && child.type === CardIcon,
113-
)
120+
return acc
121+
}, {})
114122

115123
const hasSkewEffect = colorMode === 'dark' && variant === 'torchlight'
116124
const showBorder = hasSkewEffect || hasBorder
@@ -132,7 +140,7 @@ const CardRoot = forwardRef<HTMLDivElement, CardProps>(
132140
disableAnimation && styles['Card--disableAnimation'],
133141
styles[`Card--colorMode-${colorMode}`],
134142
styles[`Card--variant-${variant}`],
135-
hasIcon && styles['Card--icon'],
143+
cardIcon && styles['Card--icon'],
136144
showBorder && styles['Card--border'],
137145
styles[`Card--colorMode-${colorMode}`],
138146
className,
@@ -141,14 +149,12 @@ const CardRoot = forwardRef<HTMLDivElement, CardProps>(
141149
ref={cardRef}
142150
{...props}
143151
>
144-
{React.Children.map(filteredChildren, child => {
145-
if (React.isValidElement(child) && typeof child.type !== 'string' && child.type === CardHeading) {
146-
return React.cloneElement<CardHeadingProps>(child as React.ReactElement<CardHeadingProps>, {
147-
href,
148-
})
149-
}
150-
return child
151-
})}
152+
{cardHeading}
153+
{cardImage}
154+
{cardIcon}
155+
{cardLabel}
156+
{cardDescription}
157+
152158
<div className={styles.Card__action}>
153159
<Text as="span" size="200" className={clsx(stylesLink['Link--label'])}>
154160
{ctaText}
@@ -239,6 +245,17 @@ const CardDescription = forwardRef<HTMLParagraphElement, CardDescriptionProps>(
239245
},
240246
)
241247

248+
const createComponentTypeGuard =
249+
<T,>(componentType: React.ComponentType<T>) =>
250+
(element: unknown): element is React.ReactElement<T> =>
251+
React.isValidElement(element) && element.type === componentType
252+
253+
const isCardImage = createComponentTypeGuard(CardImage)
254+
const isCardLabel = createComponentTypeGuard(CardLabel)
255+
const isCardIcon = createComponentTypeGuard(CardIcon)
256+
const isCardHeading = createComponentTypeGuard(CardHeading)
257+
const isCardDescription = createComponentTypeGuard(CardDescription)
258+
242259
/**
243260
* Card component:
244261
* {@link https://primer.style/brand/components/Card/ See usage examples}.

packages/react/src/Card/Card.visual.spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,15 @@ test.describe('Visual Comparison: Card', () => {
102102
expect(await page.screenshot({fullPage: true})).toMatchSnapshot()
103103
})
104104

105+
test('Card / Icon And Label With Fragment', async ({page}) => {
106+
await page.goto(
107+
'http://localhost:6006/iframe.html?args=&id=components-card-features--icon-and-label-with-fragment&viewMode=story',
108+
)
109+
110+
await page.waitForTimeout(500)
111+
expect(await page.screenshot({fullPage: true})).toMatchSnapshot()
112+
})
113+
105114
test('Card / Image', async ({page}) => {
106115
await page.goto('http://localhost:6006/iframe.html?args=&id=components-card-features--image&viewMode=story')
107116

Loading

packages/react/src/SubNav/SubNav.visual.spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,4 +200,13 @@ test.describe('Visual Comparison: SubNav', () => {
200200
await page.waitForTimeout(500)
201201
expect(await page.screenshot({fullPage: true})).toMatchSnapshot()
202202
})
203+
204+
test('SubNav / Keyboard Navigation', async ({page}) => {
205+
await page.goto(
206+
'http://localhost:6006/iframe.html?args=&id=components-subnav-features--keyboard-navigation&viewMode=story',
207+
)
208+
209+
await page.waitForTimeout(500)
210+
expect(await page.screenshot({fullPage: true})).toMatchSnapshot()
211+
})
203212
})
Loading

0 commit comments

Comments
 (0)