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} + +