Skip to content

Commit aa8e736

Browse files
fix(Answer): use useId to generate unique answer label IDs (#766)
1 parent 8d3d4f7 commit aa8e736

3 files changed

Lines changed: 120 additions & 10 deletions

File tree

src/quiz-question/answer.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from "react";
1+
import React, { useId } from "react";
22
import { RadioGroup } from "@headlessui/react";
33
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
44
import {
@@ -120,7 +120,8 @@ export const Answer = <AnswerT extends number | string>({
120120
feedback,
121121
action,
122122
}: AnswerProps<AnswerT>) => {
123-
const labelId = `quiz-answer-${value}-label`;
123+
const id = useId();
124+
const labelId = `${id}-label`;
124125

125126
const getRadioWrapperCls = () => {
126127
const cls = [...radioWrapperDefaultClasses];

src/quiz-question/quiz-question.test.tsx

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -345,14 +345,15 @@ describe("<QuizQuestion />", () => {
345345
expect(actionButton1).toBeInTheDocument();
346346
expect(actionButton2).toBeInTheDocument();
347347

348-
expect(actionButton1).toHaveAttribute(
349-
"aria-describedby",
350-
"quiz-answer-1-label",
351-
);
352-
expect(actionButton2).toHaveAttribute(
353-
"aria-describedby",
354-
"quiz-answer-2-label",
355-
);
348+
const label1Id = actionButton1.getAttribute("aria-describedby");
349+
const label2Id = actionButton2.getAttribute("aria-describedby");
350+
351+
expect(label1Id).toBeTruthy();
352+
expect(label2Id).toBeTruthy();
353+
expect(label1Id).not.toBe(label2Id);
354+
355+
expect(screen.getByText("Option 1").id).toBe(label1Id);
356+
expect(screen.getByText("Option 2").id).toBe(label2Id);
356357

357358
await userEvent.click(actionButton1);
358359
expect(handleAction1).toHaveBeenCalledTimes(1);
@@ -368,6 +369,58 @@ describe("<QuizQuestion />", () => {
368369
).not.toBeInTheDocument();
369370
});
370371

372+
it("should have unique action button aria-describedby IDs across multiple QuizQuestion instances with the same answer values", () => {
373+
const answers = [
374+
{
375+
label: "Option 1",
376+
value: 1,
377+
action: { onClick: vi.fn(), ariaLabel: "Speak option 1" },
378+
},
379+
{
380+
label: "Option 2",
381+
value: 2,
382+
action: { onClick: vi.fn(), ariaLabel: "Speak option 2" },
383+
},
384+
];
385+
386+
render(
387+
<>
388+
<QuizQuestion question="Question 1" answers={answers} />
389+
<QuizQuestion question="Question 2" answers={answers} />
390+
</>,
391+
);
392+
393+
const question1 = screen.getByRole("radiogroup", { name: "Question 1" });
394+
const question2 = screen.getByRole("radiogroup", { name: "Question 2" });
395+
396+
const speak1A = within(question1).getByRole("button", {
397+
name: "Speak option 1",
398+
});
399+
const speak1B = within(question1).getByRole("button", {
400+
name: "Speak option 2",
401+
});
402+
const speak2A = within(question2).getByRole("button", {
403+
name: "Speak option 1",
404+
});
405+
const speak2B = within(question2).getByRole("button", {
406+
name: "Speak option 2",
407+
});
408+
409+
const id1A = speak1A.getAttribute("aria-describedby")!;
410+
const id1B = speak1B.getAttribute("aria-describedby")!;
411+
const id2A = speak2A.getAttribute("aria-describedby")!;
412+
const id2B = speak2B.getAttribute("aria-describedby")!;
413+
414+
// All four IDs must be distinct
415+
expect(new Set([id1A, id1B, id2A, id2B]).size).toBe(4);
416+
417+
// Each button must point to the correct label within its question
418+
expect(within(question1).getByText("Option 1").id).toBe(id1A);
419+
expect(within(question1).getByText("Option 2").id).toBe(id1B);
420+
expect(within(question2).getByText("Option 1").id).toBe(id2A);
421+
expect(within(question2).getByText("Option 2").id).toBe(id2B);
422+
});
423+
371424
it("should not render action buttons when not provided", () => {
372425
render(
373426
<QuizQuestion

src/quiz/quiz.test.tsx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,62 @@ describe("<Quiz />", () => {
198198
expect(actionHandlers.q2a1).toHaveBeenCalledTimes(1);
199199
});
200200

201+
it("should have unique action button aria-describedby IDs across questions with the same answer values", () => {
202+
const sharedAnswers = [
203+
{
204+
label: "Option 1",
205+
value: 1,
206+
action: { onClick: vi.fn(), ariaLabel: "Speak option 1" },
207+
},
208+
{
209+
label: "Option 2",
210+
value: 2,
211+
action: { onClick: vi.fn(), ariaLabel: "Speak option 2" },
212+
},
213+
];
214+
215+
render(
216+
<Quiz
217+
questions={[
218+
{ question: "Question 1", answers: sharedAnswers, correctAnswer: 1 },
219+
{ question: "Question 2", answers: sharedAnswers, correctAnswer: 1 },
220+
]}
221+
/>,
222+
);
223+
224+
const question1 = screen.getByRole("radiogroup", {
225+
name: "1. Question 1",
226+
});
227+
const question2 = screen.getByRole("radiogroup", {
228+
name: "2. Question 2",
229+
});
230+
231+
const speak1A = within(question1).getByRole("button", {
232+
name: "Speak option 1",
233+
});
234+
const speak1B = within(question1).getByRole("button", {
235+
name: "Speak option 2",
236+
});
237+
const speak2A = within(question2).getByRole("button", {
238+
name: "Speak option 1",
239+
});
240+
const speak2B = within(question2).getByRole("button", {
241+
name: "Speak option 2",
242+
});
243+
244+
const id1A = speak1A.getAttribute("aria-describedby")!;
245+
const id1B = speak1B.getAttribute("aria-describedby")!;
246+
const id2A = speak2A.getAttribute("aria-describedby")!;
247+
const id2B = speak2B.getAttribute("aria-describedby")!;
248+
249+
expect(new Set([id1A, id1B, id2A, id2B]).size).toBe(4);
250+
251+
expect(within(question1).getByText("Option 1").id).toBe(id1A);
252+
expect(within(question1).getByText("Option 2").id).toBe(id1B);
253+
expect(within(question2).getByText("Option 1").id).toBe(id2A);
254+
expect(within(question2).getByText("Option 2").id).toBe(id2B);
255+
});
256+
201257
it("should not render action buttons when answers do not have action configuration", () => {
202258
const questions = [
203259
{

0 commit comments

Comments
 (0)