Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions frontends/main/src/app-pages/HomePage/HomePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,15 @@ describe("Home Page Carousel", () => {
test("Headings", async () => {
setupAPIs()

setMockResponse.get(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: It would be straightforward to give assertHeadings a maxLevel opt. That might be a more transparent approach.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eh, I guess that doesn't help here actually, since below we do test for h3

expect.stringContaining(urls.learningResources.list()),
[],
)
setMockResponse.get(
expect.stringContaining(urls.learningResources.featured()),
[],
)

renderWithProviders(<HomePage heroImageIndex={1} />)
await waitFor(() => {
assertHeadings([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ type ResourceCardProps = Omit<
> & {
condensed?: boolean
list?: boolean
headingLevel?: number
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,4 +291,41 @@ describe("ResourceCarousel", () => {
await screen.findByText(resources.list.results[2].title)
expect(screen.queryByText(resources.list.results[1].title)).toBeNull()
})

it.each([
{ titleComponent: "h1", expectedLevel: 2 },
{ titleComponent: "h2", expectedLevel: 3 },
{ titleComponent: "h3", expectedLevel: 4 },
{ titleComponent: "h4", expectedLevel: 5 },
{ titleComponent: "h5", expectedLevel: 6 },
{ titleComponent: "h6", expectedLevel: 6 },
] as const)(
"Resource cards have headingLevel set to next level down for screen reader navigation",
async ({ titleComponent, expectedLevel }) => {
const config: ResourceCarouselProps["config"] = [
{
label: "Resources",
data: {
type: "resources",
params: { resource_type: ["course"] },
},
},
]
const { resources } = setupApis()
renderWithProviders(
<ResourceCarousel
titleComponent={titleComponent}
title="My Carousel"
config={config}
/>,
)

const titleHeading = await screen.findByRole("heading", {
name: resources.list.results[0].title,
})
expect(titleHeading.getAttribute("aria-level")).toBe(
String(expectedLevel),
)
},
)
})
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -302,28 +311,36 @@ const ResourceCarousel: React.FC<ResourceCarouselProps> = ({
>[]
}
>
{({ resources, childrenLoading, tabConfig }) => (
<CarouselV2 arrowsContainer={ref}>
{isLoading || childrenLoading
? Array.from({ length: 6 }).map((_, index) => (
<ResourceCard
isLoading
key={index}
resource={null}
{...tabConfig.cardProps}
/>
))
: resources
.filter((resource) => resource.id !== excludeResourceId)
.map((resource) => (
{({ resources, childrenLoading, tabConfig }) => {
const headingLevel = Math.min(
headingElements.indexOf(titleComponent) + 2,
6,
)
return (
<CarouselV2 arrowsContainer={ref}>
{isLoading || childrenLoading
? Array.from({ length: 6 }).map((_, index) => (
<ResourceCard
key={resource.id}
resource={resource}
isLoading
key={index}
resource={null}
headingLevel={headingLevel}
{...tabConfig.cardProps}
/>
))}
</CarouselV2>
)}
))
: resources
.filter((resource) => resource.id !== excludeResourceId)
.map((resource) => (
<ResourceCard
key={resource.id}
resource={resource}
headingLevel={headingLevel}
{...tabConfig.cardProps}
/>
))}
</CarouselV2>
)
}}
</PanelChildren>
</TabContext>
</MobileOverflow>
Expand Down
14 changes: 14 additions & 0 deletions frontends/ol-components/src/components/Card/Card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,18 @@ describe("Card", () => {
expect(divOnClick).toHaveBeenCalled()
expect(window.location.hash).toBe("#two")
})

test("Card title has heading role and aria-level set for screen reader navigation", async () => {
renderWithTheme(
<Card>
<Card.Title role="heading" aria-level={2}>
Title
</Card.Title>
</Card>,
)
const titleHeading = screen.getByRole("heading", {
name: "Title",
})
expect(titleHeading.getAttribute("aria-level")).toBe("2")
})
})
34 changes: 29 additions & 5 deletions frontends/ol-components/src/components/Card/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -218,6 +218,7 @@ type CardProps = {
forwardClicksToLink?: boolean
onClick?: React.MouseEventHandler<HTMLElement>
as?: React.ElementType
role?: AriaRole
} & AriaAttributes

export type ImageProps = NextImageProps & {
Expand All @@ -231,7 +232,8 @@ type TitleProps = {
lines?: number
style?: CSSProperties
lang?: string
}
role?: AriaRole
} & AriaAttributes

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

Expand Down Expand Up @@ -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 = {}

Expand All @@ -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
})

Expand All @@ -315,6 +328,7 @@ const Card: Card = ({
className={allClassNames}
size={size}
onClick={handleClick}
role={role}
>
{content}
</Container>
Expand All @@ -327,6 +341,7 @@ const Card: Card = ({
className={allClassNames}
size={size}
onClick={handleClick}
role={role}
>
{image && (
// alt text will be checked on Card.Image
Expand All @@ -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.
*/}
<span role={titleRole} aria-level={titleAriaLevel}>
{title.children}
</span>
</Title>
</Body>
<Bottom>
<Footer className="MitCard-footer" {...footer}>
Expand Down
14 changes: 14 additions & 0 deletions frontends/ol-components/src/components/Card/ListCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,18 @@ describe("ListCard", () => {
expect(divOnClick).toHaveBeenCalled()
expect(window.location.hash).toBe("#two")
})

test("Card title has heading role and aria-level set for screen reader navigation", async () => {
renderWithTheme(
<ListCard>
<ListCard.Title role="heading" aria-level={2}>
Title
</ListCard.Title>
</ListCard>,
)
const titleHeading = screen.getByRole("heading", {
name: "Title",
})
expect(titleHeading.getAttribute("aria-level")).toBe("2")
})
})
41 changes: 29 additions & 12 deletions frontends/ol-components/src/components/Card/ListCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import React, {
ReactNode,
Children,
isValidElement,
AriaAttributes,
ReactElement,
} from "react"
import type { AriaRole, AriaAttributes } from "react"
import styled from "@emotion/styled"
import { RiDraggable } from "@remixicon/react"
import { theme } from "../ThemeProvider/ThemeProvider"
Expand Down Expand Up @@ -90,19 +90,20 @@ export type TitleProps = {
children?: ReactNode
href?: string
lang?: string
}
role?: AriaRole
} & AriaAttributes

export const Title: React.FC<TitleProps> = styled(Linkable)`
flex-grow: 1;
color: ${theme.custom.colors.darkGray2};
text-overflow: ellipsis;
${{ ...theme.typography.subtitle1 }}
height: ${theme.typography.pxToRem(40)};
margin: 0;
${theme.breakpoints.down("md")} {
${{ ...theme.typography.subtitle3 }}
height: ${theme.typography.pxToRem(32)};
}

margin: 0;
`

export const Footer = styled.span`
Expand Down Expand Up @@ -209,8 +210,14 @@ const ListCard: Card = ({
onClick,
...others
}) => {
let content, imageProps, info, footer, actions
let title: TitleProps = {}
let content,
imageProps,
info,
footer,
actions,
title: TitleProps = {},
titleRole: TitleProps["role"],
titleAriaLevel: TitleProps["aria-level"]
const handleHrefClick = useClickChildLink(onClick)
const handleClick = forwardClicksToLink ? handleHrefClick : onClick

Expand All @@ -220,8 +227,16 @@ const ListCard: Card = ({
if (element.type === Content) content = element.props.children
else if (element.type === Image) imageProps = element.props as ImageProps
else if (element.type === Info) info = element.props.children
else if (element.type === Title) title = element.props as TitleProps
else if (element.type === Footer) footer = element.props.children
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.children
else if (element.type === Actions) actions = element.props.children
})

Expand Down Expand Up @@ -249,10 +264,12 @@ const ListCard: Card = ({
<Body>
<Info>{info}</Info>
{title && (
<Title data-card-link={!!title.href} href={title.href}>
<TruncateText lineClamp={2} lang={title.lang}>
{title.children}
</TruncateText>
<Title data-card-link={!!title.href} {...title}>
<span role={titleRole} aria-level={titleAriaLevel}>
<TruncateText lineClamp={2} lang={title.lang}>
{title.children}
</TruncateText>
</span>
</Title>
)}
<Bottom>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,12 @@ const ListCardCondensed: Card = ({
)}
<Body>
<Info>{info}</Info>
<Title data-card-link={!!title.href} href={title.href}>
<Title
data-card-link={!!title.href}
href={title.href}
role={title.role}
aria-level={title["aria-level"]}
>
<TruncateText lineClamp={4} lang={title.lang}>
{title.children}
</TruncateText>
Expand Down
Loading
Loading