Skip to content

Commit d36c492

Browse files
authored
Card: updates labeling and internal testId support (#2859)
## Summary: When integrating Cards into the frontend repo, I ran into a few complications that I wanted to fix: - The labeling requirements made it difficult to create wrapped Cards - The labeling was also required for scenarios where it doesn't need to be -- not all sections or figures need accessible names. So I made these optional - The dismiss button was missing a testId or way to create one, so existing tests failed Issue: FEI-6310 Related `frontend` PR: Khan/frontend#5283 ## Test plan: 1. Review typing to ensure it makes sense 2. Ensure tests pass Author: marcysutton Reviewers: jandrade, marcysutton, beaesguerra Required Reviewers: Approved By: beaesguerra Checks: ✅ 12 checks were successful, ⏭️ 3 checks have been skipped Pull Request URL: #2859
1 parent 8b713e2 commit d36c492

File tree

6 files changed

+197
-43
lines changed

6 files changed

+197
-43
lines changed

.changeset/thin-plants-decide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@khanacademy/wonder-blocks-card": minor
3+
---
4+
5+
Updates labeling requirements and adds testId to dismiss button

__docs__/wonder-blocks-card/accessibility.mdx

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,28 @@ import {Meta} from "@storybook/addon-docs/blocks";
44

55
# Card Accessibility
66

7-
- When using `tag="section"` or `tag="figure"`, the `labels.cardAriaLabel` prop is required to provide an accessible name for the card.
8-
- When the `onDismiss` prop is provided, a dismiss button will be rendered. In this case, the `labels.dismissButtonAriaLabel` prop is required to provide an accessible label for the dismiss button.
9-
- Cards cannot be interactive elements themselves (e.g. buttons or links), but they can contain interactive elements as children.
7+
Cards cannot be interactive elements themselves (e.g. buttons or links), but they can contain interactive elements as children.
108

119
## HTML Semantics
1210

1311
By default, cards render as `<div>` elements. However, you can specify a different semantic element using the `tag` prop:
1412

15-
- `tag="section"` - Use when the card represents a distinct section of content. Requires an accessible name via `labels.cardAriaLabel`.
13+
- `tag="section"` - Use when the card represents a distinct section of content. Accepts an accessible name via `labels.cardAriaLabel`.
1614
- `tag="figure"` - Use when the card contains media or illustrated content. Accepts an accessible name via `labels.cardAriaLabel`.
1715
- `tag="article"` - Use when the card represents standalone content
1816
- `tag="li"` - Use when cards are part of a list (see notes about `inert` below)
1917

18+
## Labeling cards
19+
20+
When using semantic tags like `tag="section"` or `tag="figure"`, you should provide an accessible name for the Card using one of the following (in order of preference):
21+
22+
1. `labels.cardAriaLabel`: preferred for translatable strings. It automatically applies to the `aria-label` attribute. See the Dismiss Button section below for dismiss button labeling requirements.
23+
2. `aria-labelledby`: for ID references to existing elements.
24+
25+
Only one labeling mechanism can be used at a time. Refer to the [Accessible Name Computation specification](https://www.w3.org/TR/accname-1.2/#computation-steps) to understand how this is standardized.
26+
27+
When the `onDismiss` prop is provided, a dismiss button will be rendered. In this case, the `labels.dismissButtonAriaLabel` prop is required to provide a translatable, accessible label for the dismiss button.
28+
2029
## Dismiss Button
2130

2231
When `onDismiss` is provided, an accessible dismiss button is automatically added:
@@ -37,10 +46,10 @@ See the In a Stack story for an example of disabling cards while keeping them pa
3746

3847
## Best Practices
3948

40-
- Ensure cards have a logical heading structure when they contain headings
41-
- If cards are part of a collection, consider using appropriate container semantics (`<ul>`, `<ol>`, or `role="list"`)
42-
- When cards are dismissible, ensure focus is moved to an appropriate element after dismissal
43-
- Use meaningful accessible names for semantic card tags (`section`, `figure`)
49+
- Ensure cards have a logical heading structure when they contain headings.
50+
- If cards are part of a collection, consider wrapping them with appropriate container semantics (`<ul>`, `<ol>`, or `role="list"`).
51+
- When cards are dismissible, ensure focus is moved to an appropriate element after dismissal.
52+
- When applicable, use meaningful accessible names for semantic landmark tags (`section`, `figure`) so they show in the VoiceOver Rotor and NVDA Elements List.
4453

4554
## References
4655

packages/wonder-blocks-card/src/__tests__/components/card.test.tsx

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,42 @@ describe("Card", () => {
9494
});
9595
});
9696

97+
describe("Test IDs", () => {
98+
it("should apply a custom testId for card", () => {
99+
// Arrange
100+
render(
101+
<Card testId="my-card">
102+
<div>Content</div>
103+
</Card>,
104+
);
105+
106+
// Act
107+
const card = screen.getByTestId("my-card");
108+
109+
// Assert
110+
expect(card).toBeInTheDocument();
111+
});
112+
113+
it("should append -dismiss-button to testId for dismiss button", () => {
114+
// Arrange
115+
render(
116+
<Card
117+
testId="my-card"
118+
onDismiss={() => {}}
119+
labels={{dismissButtonAriaLabel: "Close"}}
120+
>
121+
<div>Content</div>
122+
</Card>,
123+
);
124+
125+
// Act
126+
const dismissButton = screen.getByTestId("my-card-dismiss-button");
127+
128+
// Assert
129+
expect(dismissButton).toBeInTheDocument();
130+
});
131+
});
132+
97133
describe("Accessibility", () => {
98134
it("should pass custom aria-label to dismiss button", () => {
99135
// Arrange
@@ -131,7 +167,7 @@ describe("Card", () => {
131167
expect(section).toBeInTheDocument();
132168
});
133169

134-
it("should apply labels.cardAriaLabel for a custom tag", () => {
170+
it("should apply labels.cardAriaLabel for a custom tag (preferred)", () => {
135171
// Arrange
136172
render(
137173
<Card
@@ -152,6 +188,50 @@ describe("Card", () => {
152188
expect(section).toBeInTheDocument();
153189
});
154190

191+
it("should apply aria-labelledby for a custom tag", () => {
192+
// Arrange
193+
render(
194+
<div>
195+
<h2 id="card-heading">My Card Title</h2>
196+
<Card tag="section" aria-labelledby="card-heading">
197+
<p>Description</p>
198+
</Card>
199+
</div>,
200+
);
201+
202+
// Act
203+
const section = screen.getByRole("region", {
204+
name: "My Card Title",
205+
});
206+
207+
// Assert
208+
expect(section).toBeInTheDocument();
209+
});
210+
211+
it("should only apply aria-labelledby", () => {
212+
// Arrange
213+
render(
214+
<div>
215+
<h2 id="card-heading">My Card Title</h2>
216+
<Card
217+
tag="section"
218+
aria-labelledby="card-heading"
219+
aria-label="Fallback label"
220+
>
221+
<p>Description</p>
222+
</Card>
223+
</div>,
224+
);
225+
226+
// Act
227+
const section = screen.getByRole("region", {
228+
name: "My Card Title",
229+
});
230+
231+
// Assert
232+
expect(section).toBeInTheDocument();
233+
});
234+
155235
it("should apply the inert attribute", () => {
156236
// Arrange
157237
render(

packages/wonder-blocks-card/src/__tests__/components/card.typestest.tsx

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -102,18 +102,52 @@ import Card from "../../components/card";
102102
Content
103103
</Card>;
104104

105-
// @ts-expect-error - section tag requires cardAriaLabel
105+
<Card tag="section" aria-label="Card section">
106+
Content
107+
</Card>;
108+
109+
<Card tag="figure" aria-label="Card figure">
110+
Content
111+
</Card>;
112+
113+
<Card tag="section" aria-labelledby="someId">
114+
<h2 id="someId">Card title</h2>
115+
</Card>;
116+
117+
<Card tag="figure" aria-labelledby="someId2">
118+
<h2 id="someId2">Card title</h2>
119+
</Card>;
120+
121+
// @ts-expect-error - aria-labelledby cannot be used with labels.cardAriaLabel
122+
<Card
123+
tag="figure"
124+
aria-labelledby="someId2"
125+
labels={{cardAriaLabel: "preferred label"}}
126+
>
127+
<h2 id="someId2">Card title</h2>
128+
</Card>;
129+
130+
// @ts-expect-error - aria-labelledby cannot be used with labels.cardAriaLabel
131+
<Card
132+
tag="section"
133+
aria-labelledby="someId2"
134+
labels={{cardAriaLabel: "preferred label"}}
135+
>
136+
<h2 id="someId2">Card title</h2>
137+
</Card>;
138+
139+
<Card tag="figure" aria-labelledby="someId2" aria-label="fallback label">
140+
<h2 id="someId2">Card title</h2>
141+
</Card>;
142+
106143
<Card tag="section">Content</Card>;
107144

108-
// @ts-expect-error - figure tag requires cardAriaLabel
109145
<Card tag="figure">Content</Card>;
110146

111-
// @ts-expect-error - section tag requires cardAriaLabel in labels
112147
<Card tag="section" labels={{}}>
113148
Content
114149
</Card>;
115150

116-
// @ts-expect-error - figure tag requires cardAriaLabel in labels
117151
<Card tag="figure" labels={{}}>
118152
Content
119153
</Card>;
@@ -132,10 +166,6 @@ import Card from "../../components/card";
132166
Content
133167
</Card>;
134168

135-
/**
136-
* Card with dismiss and section tag (complex case)
137-
*/
138-
139169
<Card
140170
tag="section"
141171
onDismiss={() => {}}
@@ -152,6 +182,8 @@ import Card from "../../components/card";
152182
*/
153183

154184
<Card
185+
aria-busy={true}
186+
aria-roledescription="A custom card"
155187
background="base-subtle"
156188
borderRadius="medium"
157189
paddingSize="medium"
@@ -169,6 +201,7 @@ import Card from "../../components/card";
169201
dismissButton: {top: 10, right: 10},
170202
}}
171203
ref={React.createRef<HTMLElement>()}
204+
role="status"
172205
>
173206
<div>Complex content</div>
174207
</Card>;

packages/wonder-blocks-card/src/components/card.tsx

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as React from "react";
22

33
import {StyleSheet} from "aphrodite";
44
import {StyleType, View} from "@khanacademy/wonder-blocks-core";
5+
import type {AriaProps} from "@khanacademy/wonder-blocks-core";
56

67
import {
78
boxShadow,
@@ -39,6 +40,9 @@ type BaseCardProps = {
3940
inert?: boolean;
4041
/**
4142
* The test ID used to locate this component in automated tests.
43+
*
44+
* The test ID will also be passed to the dismiss button as
45+
* `{testId}-dismiss-button` if the `onDismiss` prop is provided.
4246
*/
4347
testId?: string;
4448
} & StyleProps;
@@ -61,28 +65,51 @@ type DismissProps =
6165
};
6266

6367
/**
64-
* Provide a specific HTML tag that overrides the default (`div`).
65-
*
66-
* Notes:
67-
* - When `tag="section"` or `"figure"`, `cardAriaLabel` is required for accessibility.
68-
* - `button` and `a` tags are not allowed - use Wonder Blocks Button and Link components as children instead.
6968
* Valid HTML tags for the Card component.
7069
* Excludes button and anchor tags which should use Wonder Blocks Button and Link components instead.
7170
*/
72-
type ValidCardTags = Exclude<keyof JSX.IntrinsicElements, "button" | "a">;
7371

74-
type TagProps =
72+
/**
73+
* Accessibility props for the Card.
74+
*
75+
* Labeling methods (in order of preference):
76+
* 1. `labels.cardAriaLabel` - For translatable strings, automatically applied to `aria-label`
77+
* 2. `aria-labelledby` - For ID references to existing elements
78+
*
79+
* Only one labeling mechanism can be used at a time. The type system enforces this
80+
* by using discriminated unions.
81+
*/
82+
type LabelingProps =
7583
| {
76-
// Section and figure require an aria-label
77-
tag: "section" | "figure";
78-
labels: {cardAriaLabel: string} & Record<string, any>;
84+
/** Translatable label string for aria-label */
85+
labels?: {
86+
cardAriaLabel?: string;
87+
dismissButtonAriaLabel?: string;
88+
};
89+
"aria-labelledby"?: never;
7990
}
8091
| {
81-
// All other valid tags except button and a
82-
tag?: Exclude<ValidCardTags, "section" | "figure">;
83-
labels?: Record<string, any>;
92+
/** ID reference for aria-labelledby */
93+
"aria-labelledby"?: string;
94+
labels?: {
95+
cardAriaLabel?: never;
96+
dismissButtonAriaLabel?: string;
97+
};
8498
};
8599

100+
type AccessibilityProps = LabelingProps & {
101+
"aria-busy"?: AriaProps["aria-busy"];
102+
"aria-roledescription"?: AriaProps["aria-roledescription"];
103+
role?: AriaProps["role"];
104+
};
105+
106+
/**
107+
* Tag and accessibility props combined.
108+
*/
109+
type TagProps = {
110+
tag?: Exclude<keyof JSX.IntrinsicElements, "button" | "a">;
111+
} & AccessibilityProps;
112+
86113
type StyleProps = {
87114
/**
88115
* The background style of the card, as a string identifier that matches a semanticColor token.
@@ -152,7 +179,11 @@ type CardProps = BaseCardProps & TagProps & DismissProps;
152179
* </Card>
153180
* ```
154181
*
155-
* When the `onDismiss` prop is provided, a dismiss button will be rendered. In this case, the `labels.dismissButtonAriaLabel` prop is required to provide an accessible label for the dismiss button.
182+
* ### Accessibility
183+
*
184+
* When the `onDismiss` prop is provided, a dismiss button will be rendered.
185+
* In this case, the `labels.dismissButtonAriaLabel` prop is required to provide
186+
* a translatable screen reader label for the dismiss button.
156187
*
157188
* See additional Accessibility docs.
158189
*/
@@ -173,6 +204,9 @@ const Card = React.forwardRef(function Card(
173204
children,
174205
onDismiss,
175206
inert,
207+
"aria-labelledby": ariaLabelledBy,
208+
"aria-busy": ariaBusy,
209+
role,
176210
} = props;
177211

178212
const isBackgroundToken =
@@ -186,7 +220,9 @@ const Card = React.forwardRef(function Card(
186220

187221
return (
188222
<View
223+
aria-busy={ariaBusy}
189224
aria-label={labels?.cardAriaLabel}
225+
aria-labelledby={ariaLabelledBy}
190226
style={[
191227
componentStyles.root,
192228
!isBackgroundToken && {
@@ -196,6 +232,7 @@ const Card = React.forwardRef(function Card(
196232
styles?.root,
197233
]}
198234
ref={ref}
235+
role={role}
199236
tag={tag}
200237
testId={testId}
201238
{...{inert: inert ? "" : undefined}}
@@ -204,6 +241,7 @@ const Card = React.forwardRef(function Card(
204241
<DismissButton
205242
aria-label={labels?.dismissButtonAriaLabel || "Close"}
206243
onClick={(e) => onDismiss?.(e)}
244+
testId={testId && `${testId}-dismiss-button`}
207245
/>
208246
) : null}
209247
{children}

packages/wonder-blocks-card/src/components/dismiss-button.tsx

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,10 @@ type Props = {
1313
"aria-label"?: string;
1414
/** Optional custom styles. */
1515
style?: StyleType;
16-
/**
17-
* Test ID used for e2e testing.
18-
*
19-
* In this case, this component is internal, so `testId` is composed with
20-
* the `testId` passed down from the Dialog variant + a suffix to scope it
21-
* to this component.
22-
*
23-
* @example
24-
* For testId="some-random-id"
25-
* The result will be: `some-random-id-modal-panel`
26-
*/
16+
/** Test ID used for e2e testing, passed down from its parent card.*/
2717
testId?: string;
2818
};
2919

30-
// TODO[WB-2090]: Update to shared CloseButton component
3120
export const DismissButton = (props: Props) => {
3221
const {onClick, style, testId} = props;
3322
return (

0 commit comments

Comments
 (0)