From bf1d3e757c12b025b4175ef2bf93998f7b1e2828 Mon Sep 17 00:00:00 2001
From: Jon Kafton <939376+jonkafton@users.noreply.github.com>
Date: Wed, 29 Oct 2025 17:26:31 +0100
Subject: [PATCH 1/9] Headings on cards for screen reader navigation
---
.../src/app-pages/HomePage/HomePage.test.tsx | 9 +++
.../ResourceCard/ResourceCard.tsx | 1 +
.../ResourceCarousel/ResourceCarousel.tsx | 56 ++++++++++++-------
.../src/components/Card/Card.tsx | 34 +++++++++--
.../src/components/Card/ListCard.tsx | 42 ++++++++++----
.../src/components/Card/ListCardCondensed.tsx | 7 ++-
.../LearningResourceCard.test.tsx | 8 ++-
.../LearningResourceCard.tsx | 13 ++++-
.../LearningResourceListCard.tsx | 7 ++-
9 files changed, 135 insertions(+), 42 deletions(-)
diff --git a/frontends/main/src/app-pages/HomePage/HomePage.test.tsx b/frontends/main/src/app-pages/HomePage/HomePage.test.tsx
index 1ea97356d7..39bfdc8aeb 100644
--- a/frontends/main/src/app-pages/HomePage/HomePage.test.tsx
+++ b/frontends/main/src/app-pages/HomePage/HomePage.test.tsx
@@ -340,6 +340,15 @@ describe("Home Page Carousel", () => {
test("Headings", async () => {
setupAPIs()
+ setMockResponse.get(
+ expect.stringContaining(urls.learningResources.list()),
+ [],
+ )
+ setMockResponse.get(
+ expect.stringContaining(urls.learningResources.featured()),
+ [],
+ )
+
renderWithProviders()
await waitFor(() => {
assertHeadings([
diff --git a/frontends/main/src/page-components/ResourceCard/ResourceCard.tsx b/frontends/main/src/page-components/ResourceCard/ResourceCard.tsx
index cba791275e..8cd6642be9 100644
--- a/frontends/main/src/page-components/ResourceCard/ResourceCard.tsx
+++ b/frontends/main/src/page-components/ResourceCard/ResourceCard.tsx
@@ -75,6 +75,7 @@ type ResourceCardProps = Omit<
> & {
condensed?: boolean
list?: boolean
+ headingLevel?: number
}
/**
diff --git a/frontends/main/src/page-components/ResourceCarousel/ResourceCarousel.tsx b/frontends/main/src/page-components/ResourceCarousel/ResourceCarousel.tsx
index a7ea37a1f9..c1e268aab2 100644
--- a/frontends/main/src/page-components/ResourceCarousel/ResourceCarousel.tsx
+++ b/frontends/main/src/page-components/ResourceCarousel/ResourceCarousel.tsx
@@ -209,6 +209,15 @@ const getTabQuery = (tab: TabConfig): CarouselQuery => {
}
}
+const headingElements: React.ElementType[] = [
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
+]
+
/**
* A tabbed carousel that fetches resources based on the configuration provided.
* - each TabConfig generates a tab + tabpanel that pulls data from an API based
@@ -302,28 +311,37 @@ const ResourceCarousel: React.FC = ({
>[]
}
>
- {({ resources, childrenLoading, tabConfig }) => (
-
- {isLoading || childrenLoading
- ? Array.from({ length: 6 }).map((_, index) => (
-
- ))
- : resources
- .filter((resource) => resource.id !== excludeResourceId)
- .map((resource) => (
+ {({ resources, childrenLoading, tabConfig }) => {
+ const headingLevel = Math.min(
+ headingElements.indexOf(titleComponent) + 2,
+ 6,
+ )
+ console.log("headingLevel", titleComponent, headingLevel)
+ return (
+
+ {isLoading || childrenLoading
+ ? Array.from({ length: 6 }).map((_, index) => (
- ))}
-
- )}
+ ))
+ : resources
+ .filter((resource) => resource.id !== excludeResourceId)
+ .map((resource) => (
+
+ ))}
+
+ )
+ }}
diff --git a/frontends/ol-components/src/components/Card/Card.tsx b/frontends/ol-components/src/components/Card/Card.tsx
index 4b4c214ecf..18cd6f0c33 100644
--- a/frontends/ol-components/src/components/Card/Card.tsx
+++ b/frontends/ol-components/src/components/Card/Card.tsx
@@ -5,9 +5,9 @@ import React, {
isValidElement,
CSSProperties,
useCallback,
- AriaAttributes,
ReactElement,
} from "react"
+import type { AriaRole, AriaAttributes } from "react"
import styled from "@emotion/styled"
import { theme } from "../ThemeProvider/ThemeProvider"
import { pxToRem } from "../ThemeProvider/typography"
@@ -218,6 +218,7 @@ type CardProps = {
forwardClicksToLink?: boolean
onClick?: React.MouseEventHandler
as?: React.ElementType
+ role?: AriaRole
} & AriaAttributes
export type ImageProps = NextImageProps & {
@@ -231,7 +232,8 @@ type TitleProps = {
lines?: number
style?: CSSProperties
lang?: string
-}
+ role?: AriaRole
+} & AriaAttributes
type SlotProps = { children?: ReactNode; style?: CSSProperties }
@@ -270,12 +272,15 @@ const Card: Card = ({
size,
onClick,
forwardClicksToLink = false,
+ role,
...others
}) => {
let content,
image: ImageProps | null = null,
info: SlotProps = {},
title: TitleProps = {},
+ titleRole: TitleProps["role"],
+ titleAriaLevel: TitleProps["aria-level"],
footer: SlotProps = {},
actions: SlotProps = {}
@@ -298,8 +303,16 @@ const Card: Card = ({
if (element.type === Content) content = element.props.children
else if (element.type === Image) image = element.props as ImageProps
else if (element.type === Info) info = element.props as SlotProps
- else if (element.type === Title) title = element.props as TitleProps
- else if (element.type === Footer) footer = element.props as SlotProps
+ else if (element.type === Title) {
+ const {
+ role,
+ "aria-level": ariaLevel,
+ ...rest
+ } = element.props as TitleProps
+ title = rest
+ titleRole = role
+ titleAriaLevel = ariaLevel
+ } else if (element.type === Footer) footer = element.props as SlotProps
else if (element.type === Actions) actions = element.props as SlotProps
})
@@ -315,6 +328,7 @@ const Card: Card = ({
className={allClassNames}
size={size}
onClick={handleClick}
+ role={role}
>
{content}
@@ -327,6 +341,7 @@ const Card: Card = ({
className={allClassNames}
size={size}
onClick={handleClick}
+ role={role}
>
{image && (
// alt text will be checked on Card.Image
@@ -350,7 +365,16 @@ const Card: Card = ({
className="MitCard-title"
size={size}
{...title}
- />
+ >
+ {/*
+ * The card titles are links, but we also want them to be visible as headings for accessibility.
+ * Setting the role on the Title component would make it invisible as a link to screen readers,
+ * so we include a span to set the role on instead.
+ */}
+
+ {title.children}
+
+