Skip to content

Commit 8c86195

Browse files
authored
Form: Fix a11y issue on RadioGroup and ChoiceGroup (#2328)
## Summary: `RadioGroup` and `ChoiceGroup` components are not associating correctly their labels with their `fieldset` elements. The underlying issue is that these components are appending a `View` element as the only child of the `fieldset` element, which is causing the `fieldset` element to not associate correctly the `legend` value as its label. This PR fixes that issue by removing the `View` element and using the `legend` element directly as the first child of the `fieldset` element. Issue: XXX-XXXX ## Test plan: Navigate to: - CheckboxGroup: /?path=/docs/packages-form-checkboxgroup--docs - RadioGroup: /?path=/docs/packages-form-radiogroup--docs Verify that the first example associates the `label` value with the `fieldset` element correctly. | BEFORE | AFTER | |--------|--------| | <img width="1122" alt="Screenshot 2024-09-19 at 5 10 44 PM" src="https://github.com/user-attachments/assets/8e52b298-7280-4215-8b28-481363762be5"> | <img width="1067" alt="Screenshot 2024-09-19 at 5 09 48 PM" src="https://github.com/user-attachments/assets/bc30b90f-21cd-460e-98ff-ed9ebee0003a"> | | <img width="1087" alt="Screenshot 2024-09-19 at 5 34 49 PM" src="https://github.com/user-attachments/assets/e7ccc91d-9b06-49b0-8da3-31accdccaa69"> | <img width="882" alt="Screenshot 2024-09-19 at 5 33 58 PM" src="https://github.com/user-attachments/assets/c8c8115a-454f-4d6d-967b-c04936eb7076"> | Author: jandrade Reviewers: jandrade, beaesguerra Required Reviewers: Approved By: beaesguerra Checks: ✅ Chromatic - Get results on regular PRs (ubuntu-latest, 20.x), ✅ Test (ubuntu-latest, 20.x, 2/2), ✅ Test (ubuntu-latest, 20.x, 1/2), ✅ Check build sizes (ubuntu-latest, 20.x), ✅ Lint (ubuntu-latest, 20.x), ✅ Chromatic - Build on regular PRs / chromatic (ubuntu-latest, 20.x), ✅ Publish npm snapshot (ubuntu-latest, 20.x), ⏭️ Chromatic - Skip on Release PR (changesets), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ gerald, ✅ Prime node_modules cache for primary configuration (ubuntu-latest, 20.x), ✅ Chromatic - Get results on regular PRs (ubuntu-latest, 20.x), ✅ Test (ubuntu-latest, 20.x, 2/2), ✅ Test (ubuntu-latest, 20.x, 1/2), ✅ Check build sizes (ubuntu-latest, 20.x), ✅ Lint (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Chromatic - Build on regular PRs / chromatic (ubuntu-latest, 20.x), ⏭️ Chromatic - Skip on Release PR (changesets), ✅ Prime node_modules cache for primary configuration (ubuntu-latest, 20.x), ✅ gerald, ⏭️ dependabot, ✅ Publish npm snapshot (ubuntu-latest, 20.x) Pull Request URL: #2328
1 parent 0b3a28a commit 8c86195

File tree

8 files changed

+147
-74
lines changed

8 files changed

+147
-74
lines changed

.changeset/fresh-onions-peel.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@khanacademy/wonder-blocks-form": patch
3+
---
4+
5+
Modify `RadioGroup` and `CheckboxGroup` to append `legend` as the first child in `fieldset`, so the accessibility tree can associate the legend contents with the fieldset group and announce its label correctly

__docs__/wonder-blocks-form/checkbox-group.stories.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ export default {
2626

2727
type StoryComponentType = StoryObj<typeof CheckboxGroup>;
2828

29+
/**
30+
* `CheckboxGroup` is a component that groups multiple `Choice` components
31+
* together. It is used to allow users to select multiple options from a list.
32+
*
33+
* Note that by using a `label` prop, the `CheckboxGroup` component will render
34+
* a `legend` as the first child of the `fieldset` element. This is important to
35+
* include as it ensures that Screen Readers can correctly identify and announce
36+
* the group of checkboxes.
37+
*/
2938
export const Default: StoryComponentType = {
3039
render: (args) => {
3140
return (

__docs__/wonder-blocks-form/radio-group.stories.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ export default {
2424

2525
type StoryComponentType = StoryObj<typeof RadioGroup>;
2626

27+
/**
28+
* `RadioGroup` is a component that groups multiple `Choice` components
29+
* together. It is used to allow users to select a single option from a list.
30+
*
31+
* Note that by using a `label` prop, the `RadioGroup` component will render
32+
* a `legend` as the first child of the `fieldset` element. This is important to
33+
* include as it ensures that Screen Readers can correctly identify and announce
34+
* the group of radio buttons.
35+
*/
2736
export const Default: StoryComponentType = {
2837
render: (args) => {
2938
return (

packages/wonder-blocks-form/src/components/__tests__/checkbox-group.test.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,29 @@ import CheckboxGroup from "../checkbox-group";
66
import Choice from "../choice";
77

88
describe("CheckboxGroup", () => {
9+
describe("a11y", () => {
10+
it("should associate the label with the fieldset", async () => {
11+
// Arrange, Act
12+
render(
13+
<CheckboxGroup
14+
label="Test label"
15+
groupName="test"
16+
onChange={() => {}}
17+
selectedValues={[]}
18+
>
19+
<Choice label="a" value="a" aria-labelledby="test-a" />
20+
<Choice label="b" value="b" aria-labelledby="test-b" />
21+
<Choice label="c" value="c" aria-labelledby="test-c" />
22+
</CheckboxGroup>,
23+
);
24+
25+
const fieldset = screen.getByRole("group", {name: /test label/i});
26+
27+
// Assert
28+
expect(fieldset).toBeInTheDocument();
29+
});
30+
});
31+
932
describe("behavior", () => {
1033
const TestComponent = ({errorMessage}: {errorMessage?: string}) => {
1134
const [selectedValues, setSelectedValue] = React.useState([

packages/wonder-blocks-form/src/components/__tests__/radio-group.test.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,29 @@ describe("RadioGroup", () => {
3434
);
3535
};
3636

37+
describe("a11y", () => {
38+
it("should associate the label with the fieldset", async () => {
39+
// Arrange, Act
40+
render(
41+
<RadioGroup
42+
label="Test label"
43+
groupName="test"
44+
onChange={() => {}}
45+
selectedValue="a"
46+
>
47+
<Choice label="a" value="a" aria-labelledby="test-a" />
48+
<Choice label="b" value="b" aria-labelledby="test-b" />
49+
<Choice label="c" value="c" aria-labelledby="test-c" />
50+
</RadioGroup>,
51+
);
52+
53+
const fieldset = screen.getByRole("group", {name: /test label/i});
54+
55+
// Assert
56+
expect(fieldset).toBeInTheDocument();
57+
});
58+
});
59+
3760
describe("behavior", () => {
3861
it("selects only one item at a time", async () => {
3962
// Arrange, Act

packages/wonder-blocks-form/src/components/checkbox-group.tsx

Lines changed: 38 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as React from "react";
22

3-
import {View, addStyle} from "@khanacademy/wonder-blocks-core";
3+
import {addStyle} from "@khanacademy/wonder-blocks-core";
44
import {Strut} from "@khanacademy/wonder-blocks-layout";
55
import {spacing} from "@khanacademy/wonder-blocks-tokens";
66
import {LabelMedium, LabelSmall} from "@khanacademy/wonder-blocks-typography";
@@ -130,43 +130,44 @@ const CheckboxGroup = React.forwardRef(function CheckboxGroup(
130130
const allChildren = React.Children.toArray(children).filter(Boolean);
131131

132132
return (
133-
<StyledFieldset data-testid={testId} style={styles.fieldset} ref={ref}>
134-
{/* We have a View here because fieldset cannot be used with flexbox*/}
135-
<View style={style}>
136-
{label && (
137-
<StyledLegend style={styles.legend}>
138-
<LabelMedium>{label}</LabelMedium>
139-
</StyledLegend>
140-
)}
141-
{description && (
142-
<LabelSmall style={styles.description}>
143-
{description}
144-
</LabelSmall>
145-
)}
146-
{errorMessage && (
147-
<LabelSmall style={styles.error}>{errorMessage}</LabelSmall>
148-
)}
149-
{(label || description || errorMessage) && (
150-
<Strut size={spacing.small_12} />
151-
)}
133+
<StyledFieldset
134+
data-testid={testId}
135+
style={[styles.fieldset, style]}
136+
ref={ref}
137+
>
138+
{label && (
139+
<StyledLegend style={styles.legend}>
140+
<LabelMedium>{label}</LabelMedium>
141+
</StyledLegend>
142+
)}
143+
{description && (
144+
<LabelSmall style={styles.description}>
145+
{description}
146+
</LabelSmall>
147+
)}
148+
{errorMessage && (
149+
<LabelSmall style={styles.error}>{errorMessage}</LabelSmall>
150+
)}
151+
{(label || description || errorMessage) && (
152+
<Strut size={spacing.small_12} />
153+
)}
152154

153-
{allChildren.map((child, index) => {
154-
// @ts-expect-error [FEI-5019] - TS2339 - Property 'props' does not exist on type 'ReactChild | ReactFragment | ReactPortal'.
155-
const {style, value} = child.props;
156-
const checked = selectedValues.includes(value);
157-
// @ts-expect-error [FEI-5019] - TS2769 - No overload matches this call.
158-
return React.cloneElement(child, {
159-
checked: checked,
160-
error: !!errorMessage,
161-
groupName: groupName,
162-
id: `${groupName}-${value}`,
163-
key: value,
164-
onChange: () => handleChange(value, checked),
165-
style: [index > 0 && styles.defaultLineGap, style],
166-
variant: "checkbox",
167-
});
168-
})}
169-
</View>
155+
{allChildren.map((child, index) => {
156+
// @ts-expect-error [FEI-5019] - TS2339 - Property 'props' does not exist on type 'ReactChild | ReactFragment | ReactPortal'.
157+
const {style, value} = child.props;
158+
const checked = selectedValues.includes(value);
159+
// @ts-expect-error [FEI-5019] - TS2769 - No overload matches this call.
160+
return React.cloneElement(child, {
161+
checked: checked,
162+
error: !!errorMessage,
163+
groupName: groupName,
164+
id: `${groupName}-${value}`,
165+
key: value,
166+
onChange: () => handleChange(value, checked),
167+
style: [index > 0 && styles.defaultLineGap, style],
168+
variant: "checkbox",
169+
});
170+
})}
170171
</StyledFieldset>
171172
);
172173
});

packages/wonder-blocks-form/src/components/group-styles.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import type {StyleDeclaration} from "aphrodite";
66

77
const styles: StyleDeclaration = StyleSheet.create({
88
fieldset: {
9+
display: "flex",
10+
flexDirection: "column",
911
border: "none",
1012
padding: 0,
1113
margin: 0,

packages/wonder-blocks-form/src/components/radio-group.tsx

Lines changed: 38 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as React from "react";
22

3-
import {View, addStyle} from "@khanacademy/wonder-blocks-core";
3+
import {addStyle} from "@khanacademy/wonder-blocks-core";
44
import {Strut} from "@khanacademy/wonder-blocks-layout";
55
import {spacing} from "@khanacademy/wonder-blocks-tokens";
66
import {LabelMedium, LabelSmall} from "@khanacademy/wonder-blocks-typography";
@@ -115,43 +115,44 @@ const RadioGroup = React.forwardRef(function RadioGroup(
115115
const allChildren = React.Children.toArray(children).filter(Boolean);
116116

117117
return (
118-
<StyledFieldset data-testid={testId} style={styles.fieldset} ref={ref}>
119-
{/* We have a View here because fieldset cannot be used with flexbox*/}
120-
<View style={style}>
121-
{label && (
122-
<StyledLegend style={styles.legend}>
123-
<LabelMedium>{label}</LabelMedium>
124-
</StyledLegend>
125-
)}
126-
{description && (
127-
<LabelSmall style={styles.description}>
128-
{description}
129-
</LabelSmall>
130-
)}
131-
{errorMessage && (
132-
<LabelSmall style={styles.error}>{errorMessage}</LabelSmall>
133-
)}
134-
{(label || description || errorMessage) && (
135-
<Strut size={spacing.small_12} />
136-
)}
118+
<StyledFieldset
119+
data-testid={testId}
120+
style={[styles.fieldset, style]}
121+
ref={ref}
122+
>
123+
{label && (
124+
<StyledLegend style={styles.legend}>
125+
<LabelMedium>{label}</LabelMedium>
126+
</StyledLegend>
127+
)}
128+
{description && (
129+
<LabelSmall style={styles.description}>
130+
{description}
131+
</LabelSmall>
132+
)}
133+
{errorMessage && (
134+
<LabelSmall style={styles.error}>{errorMessage}</LabelSmall>
135+
)}
136+
{(label || description || errorMessage) && (
137+
<Strut size={spacing.small_12} />
138+
)}
137139

138-
{allChildren.map((child, index) => {
139-
// @ts-expect-error [FEI-5019] - TS2339 - Property 'props' does not exist on type 'ReactChild | ReactFragment | ReactPortal'.
140-
const {style, value} = child.props;
141-
const checked = selectedValue === value;
142-
// @ts-expect-error [FEI-5019] - TS2769 - No overload matches this call.
143-
return React.cloneElement(child, {
144-
checked: checked,
145-
error: !!errorMessage,
146-
groupName: groupName,
147-
id: `${groupName}-${value}`,
148-
key: value,
149-
onChange: () => onChange(value),
150-
style: [index > 0 && styles.defaultLineGap, style],
151-
variant: "radio",
152-
});
153-
})}
154-
</View>
140+
{allChildren.map((child, index) => {
141+
// @ts-expect-error [FEI-5019] - TS2339 - Property 'props' does not exist on type 'ReactChild | ReactFragment | ReactPortal'.
142+
const {style, value} = child.props;
143+
const checked = selectedValue === value;
144+
// @ts-expect-error [FEI-5019] - TS2769 - No overload matches this call.
145+
return React.cloneElement(child, {
146+
checked: checked,
147+
error: !!errorMessage,
148+
groupName: groupName,
149+
id: `${groupName}-${value}`,
150+
key: value,
151+
onChange: () => onChange(value),
152+
style: [index > 0 && styles.defaultLineGap, style],
153+
variant: "radio",
154+
});
155+
})}
155156
</StyledFieldset>
156157
);
157158
});

0 commit comments

Comments
 (0)