Skip to content

Commit 31ff8cb

Browse files
authored
Resource card headings for screen reader navigation (#2658)
* Headings on cards for screen reader navigation * Add tests * Stray character * Styles not in use * Remove console.log * Style lint * Refactor to LinkableTitle component * Move heading level selection into ResourceCard for reuse in SearchDisplay * Set heading level
1 parent fe69436 commit 31ff8cb

File tree

14 files changed

+238
-58
lines changed

14 files changed

+238
-58
lines changed

frontends/main/src/app-pages/HomePage/HomePage.test.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,15 @@ describe("Home Page Carousel", () => {
340340
test("Headings", async () => {
341341
setupAPIs()
342342

343+
setMockResponse.get(
344+
expect.stringContaining(urls.learningResources.list()),
345+
[],
346+
)
347+
setMockResponse.get(
348+
expect.stringContaining(urls.learningResources.featured()),
349+
[],
350+
)
351+
343352
renderWithProviders(<HomePage heroImageIndex={1} />)
344353
await waitFor(() => {
345354
assertHeadings([

frontends/main/src/page-components/ResourceCard/ResourceCard.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,25 @@ const useResourceCard = (resource?: LearningResource | null) => {
6969
}
7070
}
7171

72+
type HeadingElement = "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
73+
74+
const subheadingMap: Record<HeadingElement, number> = {
75+
h1: 2,
76+
h2: 3,
77+
h3: 4,
78+
h4: 5,
79+
h5: 6,
80+
h6: 6,
81+
}
82+
7283
type ResourceCardProps = Omit<
7384
LearningResourceCardProps,
7485
"href" | "onAddToLearningPathClick" | "onAddToUserListClick"
7586
> & {
7687
condensed?: boolean
7788
list?: boolean
89+
headingLevel?: number
90+
parentHeadingEl?: HeadingElement
7891
}
7992

8093
/**
@@ -88,6 +101,7 @@ const ResourceCard: React.FC<ResourceCardProps> = ({
88101
resource,
89102
condensed,
90103
list,
104+
parentHeadingEl,
91105
...others
92106
}) => {
93107
const {
@@ -106,6 +120,8 @@ const ResourceCard: React.FC<ResourceCardProps> = ({
106120
: list
107121
? LearningResourceListCard
108122
: LearningResourceCard
123+
124+
const headingLevel = parentHeadingEl ? subheadingMap[parentHeadingEl] : 6
109125
return (
110126
<>
111127
<CardComponent
@@ -116,6 +132,7 @@ const ResourceCard: React.FC<ResourceCardProps> = ({
116132
onAddToUserListClick={handleAddToUserListClick}
117133
inUserList={inUserList}
118134
inLearningPath={inLearningPath}
135+
headingLevel={headingLevel}
119136
{...others}
120137
/>
121138
<SignupPopover anchorEl={anchorEl} onClose={handleClosePopover} />

frontends/main/src/page-components/ResourceCarousel/ResourceCarousel.test.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,4 +291,41 @@ describe("ResourceCarousel", () => {
291291
await screen.findByText(resources.list.results[2].title)
292292
expect(screen.queryByText(resources.list.results[1].title)).toBeNull()
293293
})
294+
295+
it.each([
296+
{ titleComponent: "h1", expectedLevel: 2 },
297+
{ titleComponent: "h2", expectedLevel: 3 },
298+
{ titleComponent: "h3", expectedLevel: 4 },
299+
{ titleComponent: "h4", expectedLevel: 5 },
300+
{ titleComponent: "h5", expectedLevel: 6 },
301+
{ titleComponent: "h6", expectedLevel: 6 },
302+
] as const)(
303+
"Resource cards have headingLevel set to next level down for screen reader navigation",
304+
async ({ titleComponent, expectedLevel }) => {
305+
const config: ResourceCarouselProps["config"] = [
306+
{
307+
label: "Resources",
308+
data: {
309+
type: "resources",
310+
params: { resource_type: ["course"] },
311+
},
312+
},
313+
]
314+
const { resources } = setupApis()
315+
renderWithProviders(
316+
<ResourceCarousel
317+
titleComponent={titleComponent}
318+
title="My Carousel"
319+
config={config}
320+
/>,
321+
)
322+
323+
const titleHeading = await screen.findByRole("heading", {
324+
name: resources.list.results[0].title,
325+
})
326+
expect(titleHeading.getAttribute("aria-level")).toBe(
327+
String(expectedLevel),
328+
)
329+
},
330+
)
294331
})

frontends/main/src/page-components/ResourceCarousel/ResourceCarousel.tsx

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ type ResourceCarouselProps = {
176176
/**
177177
* Element type for the carousel title
178178
*/
179-
titleComponent?: React.ElementType
179+
titleComponent?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
180180
titleVariant?: TypographyProps["variant"]
181181
excludeResourceId?: number
182182
}
@@ -302,28 +302,32 @@ const ResourceCarousel: React.FC<ResourceCarouselProps> = ({
302302
>[]
303303
}
304304
>
305-
{({ resources, childrenLoading, tabConfig }) => (
306-
<CarouselV2 arrowsContainer={ref}>
307-
{isLoading || childrenLoading
308-
? Array.from({ length: 6 }).map((_, index) => (
309-
<ResourceCard
310-
isLoading
311-
key={index}
312-
resource={null}
313-
{...tabConfig.cardProps}
314-
/>
315-
))
316-
: resources
317-
.filter((resource) => resource.id !== excludeResourceId)
318-
.map((resource) => (
305+
{({ resources, childrenLoading, tabConfig }) => {
306+
return (
307+
<CarouselV2 arrowsContainer={ref}>
308+
{isLoading || childrenLoading
309+
? Array.from({ length: 6 }).map((_, index) => (
319310
<ResourceCard
320-
key={resource.id}
321-
resource={resource}
311+
isLoading
312+
key={index}
313+
resource={null}
314+
parentHeadingEl={titleComponent}
322315
{...tabConfig.cardProps}
323316
/>
324-
))}
325-
</CarouselV2>
326-
)}
317+
))
318+
: resources
319+
.filter((resource) => resource.id !== excludeResourceId)
320+
.map((resource) => (
321+
<ResourceCard
322+
key={resource.id}
323+
resource={resource}
324+
parentHeadingEl={titleComponent}
325+
{...tabConfig.cardProps}
326+
/>
327+
))}
328+
</CarouselV2>
329+
)
330+
}}
327331
</PanelChildren>
328332
</TabContext>
329333
</MobileOverflow>

frontends/main/src/page-components/SearchDisplay/SearchDisplay.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -516,7 +516,7 @@ interface SearchDisplayProps {
516516
toggleParamValue: UseResourceSearchParamsResult["toggleParamValue"]
517517
showProfessionalToggle?: boolean
518518
setSearchParams: UseResourceSearchParamsProps["setSearchParams"]
519-
resultsHeadingEl: React.ElementType
519+
resultsHeadingEl: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
520520
filterHeadingEl: React.ElementType
521521
}
522522

@@ -914,15 +914,23 @@ const SearchDisplay: React.FC<SearchDisplayProps> = ({
914914
.fill(null)
915915
.map((a, index) => (
916916
<li key={index}>
917-
<ResourceCard isLoading={isLoading} list />
917+
<ResourceCard
918+
isLoading={isLoading}
919+
parentHeadingEl={resultsHeadingEl}
920+
list
921+
/>
918922
</li>
919923
))}
920924
</PlainList>
921925
) : data && data.count > 0 ? (
922926
<PlainList itemSpacing={1.5}>
923927
{data.results.map((resource) => (
924928
<li key={resource.id}>
925-
<ResourceCard resource={resource} list />
929+
<ResourceCard
930+
resource={resource}
931+
parentHeadingEl={resultsHeadingEl}
932+
list
933+
/>
926934
</li>
927935
))}
928936
</PlainList>

frontends/ol-components/src/components/Card/Card.test.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,4 +131,18 @@ describe("Card", () => {
131131
expect(divOnClick).toHaveBeenCalled()
132132
expect(window.location.hash).toBe("#two")
133133
})
134+
135+
test("Card title has heading role and aria-level set for screen reader navigation", async () => {
136+
renderWithTheme(
137+
<Card>
138+
<Card.Title role="heading" aria-level={2}>
139+
Title
140+
</Card.Title>
141+
</Card>,
142+
)
143+
const titleHeading = screen.getByRole("heading", {
144+
name: "Title",
145+
})
146+
expect(titleHeading.getAttribute("aria-level")).toBe("2")
147+
})
134148
})

frontends/ol-components/src/components/Card/Card.tsx

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import React, {
55
isValidElement,
66
CSSProperties,
77
useCallback,
8-
AriaAttributes,
98
ReactElement,
109
} from "react"
10+
import type { AriaRole, AriaAttributes } from "react"
1111
import styled from "@emotion/styled"
1212
import { theme } from "../ThemeProvider/ThemeProvider"
1313
import { pxToRem } from "../ThemeProvider/typography"
@@ -135,6 +135,35 @@ const Title = styled(
135135
]
136136
})
137137

138+
const LinkableTitle = ({
139+
title,
140+
size,
141+
}: {
142+
title: TitleProps
143+
size: Size | undefined
144+
}) => {
145+
const { role, "aria-level": ariaLevel, href, children, ...rest } = title
146+
147+
return (
148+
<Title
149+
data-card-link={!!href}
150+
className="MitCard-title"
151+
size={size}
152+
href={href}
153+
{...rest}
154+
>
155+
{/*
156+
* The card titles are links, but we also want them to be visible as headings for accessibility.
157+
* Setting the role on the Title component would make it invisible as a link to screen readers,
158+
* so we include a span to set the role on instead.
159+
*/}
160+
<span role={role} aria-level={ariaLevel}>
161+
{children}
162+
</span>
163+
</Title>
164+
)
165+
}
166+
138167
const Footer = styled.span`
139168
display: block;
140169
height: ${pxToRem(16)};
@@ -218,6 +247,7 @@ type CardProps = {
218247
forwardClicksToLink?: boolean
219248
onClick?: React.MouseEventHandler<HTMLElement>
220249
as?: React.ElementType
250+
role?: AriaRole
221251
} & AriaAttributes
222252

223253
export type ImageProps = NextImageProps & {
@@ -231,7 +261,8 @@ type TitleProps = {
231261
lines?: number
232262
style?: CSSProperties
233263
lang?: string
234-
}
264+
role?: AriaRole
265+
} & AriaAttributes
235266

236267
type SlotProps = { children?: ReactNode; style?: CSSProperties }
237268

@@ -270,6 +301,7 @@ const Card: Card = ({
270301
size,
271302
onClick,
272303
forwardClicksToLink = false,
304+
role,
273305
...others
274306
}) => {
275307
let content,
@@ -315,6 +347,7 @@ const Card: Card = ({
315347
className={allClassNames}
316348
size={size}
317349
onClick={handleClick}
350+
role={role}
318351
>
319352
{content}
320353
</Container>
@@ -327,6 +360,7 @@ const Card: Card = ({
327360
className={allClassNames}
328361
size={size}
329362
onClick={handleClick}
363+
role={role}
330364
>
331365
{image && (
332366
// alt text will be checked on Card.Image
@@ -345,12 +379,7 @@ const Card: Card = ({
345379
{info.children}
346380
</Info>
347381
)}
348-
<Title
349-
data-card-link={!!title.href}
350-
className="MitCard-title"
351-
size={size}
352-
{...title}
353-
/>
382+
<LinkableTitle title={title} size={size} />
354383
</Body>
355384
<Bottom>
356385
<Footer className="MitCard-footer" {...footer}>

frontends/ol-components/src/components/Card/ListCard.test.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,18 @@ describe("ListCard", () => {
107107
expect(divOnClick).toHaveBeenCalled()
108108
expect(window.location.hash).toBe("#two")
109109
})
110+
111+
test("Card title has heading role and aria-level set for screen reader navigation", async () => {
112+
renderWithTheme(
113+
<ListCard>
114+
<ListCard.Title role="heading" aria-level={2}>
115+
Title
116+
</ListCard.Title>
117+
</ListCard>,
118+
)
119+
const titleHeading = screen.getByRole("heading", {
120+
name: "Title",
121+
})
122+
expect(titleHeading.getAttribute("aria-level")).toBe("2")
123+
})
110124
})

0 commit comments

Comments
 (0)