Skip to content

Commit daef548

Browse files
committed
VPR-104 fix(a11y): redesign MyAssessments and AssessmentBubble
Split out from VPR-104-accessibility-audit-CTS. Rework AssessmentBubble into a button/span pair with proper roles and labels, restyle the rating bubbles as filled chips, and rebuild the MyAssessments layout with an h1/h2 hierarchy, accessible expand/collapse controls, and a level-chip dialog. Adds test coverage for AssessmentBubble.
1 parent 5cb764f commit daef548

4 files changed

Lines changed: 370 additions & 113 deletions

File tree

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { mount } from "@vue/test-utils"
2+
import { Quasar } from "quasar"
3+
4+
import AssessmentBubble from "../components/AssessmentBubble.vue"
5+
6+
/**
7+
* Tests for AssessmentBubble — the rating dot rendered on CTS assessment lists.
8+
*
9+
* Focus areas:
10+
* 1. Privacy: aria-label must surface the descriptive rating label, never the
11+
* numeric value. Students should not hear "Rating 1 of 5" from a screen
12+
* reader when they are low-rated.
13+
* 2. Class mapping: value/maxValue drive the bubbleClass contract consumed
14+
* by cts.css.
15+
* 3. Click contract: clickable variant (id prop set) emits bubble-click with
16+
* the id; non-clickable variant renders as a non-interactive span.
17+
*/
18+
19+
function createWrapper(props: Record<string, unknown>) {
20+
return mount(AssessmentBubble, {
21+
props: props as never,
22+
global: {
23+
plugins: [[Quasar, {}]],
24+
},
25+
})
26+
}
27+
28+
describe(AssessmentBubble, () => {
29+
describe("aria-label privacy", () => {
30+
it("uses levelName on the clickable button and does not expose the numeric value", () => {
31+
const wrapper = createWrapper({
32+
maxValue: 5,
33+
value: 1,
34+
levelName: "Trust with indirect supervision",
35+
id: 42,
36+
})
37+
38+
const label = wrapper.get("button").attributes("aria-label")!
39+
expect(label).toContain("Trust with indirect supervision")
40+
expect(label).not.toMatch(/\b1 of 5\b/i)
41+
expect(label).not.toMatch(/rating\s+\d/i)
42+
})
43+
44+
it("uses levelName on the standalone span and does not expose the numeric value", () => {
45+
const wrapper = createWrapper({
46+
maxValue: 5,
47+
value: 2,
48+
levelName: "Trust with direct supervision",
49+
})
50+
51+
const label = wrapper.get('span[role="img"]').attributes("aria-label")!
52+
expect(label).toBe("Trust with direct supervision")
53+
expect(label).not.toMatch(/\b2 of 5\b/i)
54+
})
55+
56+
it("appends open-details hint on the clickable variant", () => {
57+
const wrapper = createWrapper({
58+
maxValue: 5,
59+
value: 3,
60+
levelName: "Independent remote supervision",
61+
id: 7,
62+
})
63+
64+
expect(wrapper.get("button").attributes("aria-label")).toBe(
65+
"Independent remote supervision, open assessment details",
66+
)
67+
})
68+
69+
it("falls back to a generic hint when levelName is missing on a clickable bubble", () => {
70+
const wrapper = createWrapper({
71+
maxValue: 5,
72+
value: 3,
73+
id: 7,
74+
})
75+
76+
expect(wrapper.get("button").attributes("aria-label")).toBe("Open assessment details")
77+
})
78+
})
79+
80+
describe("bubbleClass contract", () => {
81+
it.each([
82+
[1, "assessmentBubble5_1"],
83+
[2, "assessmentBubble5_2"],
84+
[3, "assessmentBubble5_3"],
85+
[4, "assessmentBubble5_4"],
86+
[5, "assessmentBubble5_5"],
87+
])("maps value=%i to %s", (value, expected) => {
88+
const wrapper = createWrapper({
89+
maxValue: 5,
90+
value,
91+
levelName: "Label",
92+
})
93+
94+
expect(wrapper.get('span[role="img"]').classes()).toContain(expected)
95+
})
96+
97+
it.each([0, 6])("yields no level class for out-of-range value=%i", (value) => {
98+
const wrapper = createWrapper({
99+
maxValue: 5,
100+
value,
101+
levelName: "Label",
102+
})
103+
104+
const classes = wrapper.get('span[role="img"]').classes()
105+
expect(classes.some((c) => c.startsWith("assessmentBubble5_"))).toBeFalsy()
106+
})
107+
108+
it("yields no level class when maxValue is not 5", () => {
109+
const wrapper = createWrapper({
110+
maxValue: 3,
111+
value: 2,
112+
levelName: "Label",
113+
})
114+
115+
const classes = wrapper.get('span[role="img"]').classes()
116+
expect(classes.some((c) => c.startsWith("assessmentBubble5_"))).toBeFalsy()
117+
})
118+
})
119+
120+
describe("click behaviour", () => {
121+
it("renders a button and emits bubble-click with the id when clicked", async () => {
122+
const wrapper = createWrapper({
123+
maxValue: 5,
124+
value: 3,
125+
levelName: "Label",
126+
id: 99,
127+
})
128+
129+
await wrapper.get("button").trigger("click")
130+
131+
expect(wrapper.emitted("bubble-click")).toEqual([[99]])
132+
})
133+
134+
it("renders a non-interactive span and does not emit when id is omitted", () => {
135+
const wrapper = createWrapper({
136+
maxValue: 5,
137+
value: 3,
138+
levelName: "Label",
139+
})
140+
141+
expect(wrapper.find("button").exists()).toBeFalsy()
142+
expect(wrapper.find('span[role="img"]').exists()).toBeTruthy()
143+
expect(wrapper.emitted("bubble-click")).toBeUndefined()
144+
})
145+
})
146+
147+
describe("bubble content", () => {
148+
it("does not render the numeric value inside the bubble", () => {
149+
const wrapper = createWrapper({
150+
maxValue: 5,
151+
value: 4,
152+
levelName: "Label",
153+
id: 1,
154+
})
155+
156+
const bubble = wrapper.get("span.assessmentBubble")
157+
expect(bubble.text()).toBe("")
158+
})
159+
})
160+
})

VueApp/src/CTS/assets/cts.css

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
.assessmentGroup {
22
border-top: 1px solid silver;
33
}
4+
5+
.expandToggleCol {
6+
flex: 0 0 2.5rem;
7+
max-width: 2.5rem;
8+
text-align: right;
9+
}
410
/*
511
.assessmentbubble {
612
width: 15px;
@@ -51,24 +57,65 @@
5157
color: rgba(11,3,139,1);
5258
}
5359
*/
60+
.assessmentBubbleTrigger {
61+
background: none;
62+
border: 0;
63+
padding: 0;
64+
margin: 0 0.25rem 0 0;
65+
line-height: 0;
66+
color: inherit;
67+
cursor: pointer;
68+
}
69+
70+
.assessmentBubble[role="img"] {
71+
margin-right: 0.25rem;
72+
}
73+
74+
.assessmentBubbleTooltipText {
75+
white-space: pre-wrap;
76+
}
77+
78+
.assessmentBubbleTrigger:focus-visible {
79+
outline: 2px solid var(--q-primary);
80+
outline-offset: 2px;
81+
border-radius: 50%;
82+
}
83+
84+
.assessmentBubble {
85+
display: inline-flex;
86+
align-items: center;
87+
justify-content: center;
88+
width: 1.5rem;
89+
height: 1.5rem;
90+
border-radius: 50%;
91+
font-size: 0.75rem;
92+
font-weight: 600;
93+
line-height: 1;
94+
}
95+
5496
.assessmentBubble5_1 {
55-
color: rgba(62, 127, 238, 0.3);
97+
background-color: rgba(62, 127, 238, 0.3);
98+
color: #212529;
5699
}
57100

58101
.assessmentBubble5_2 {
59-
color: rgba(62, 127, 238, 0.7);
102+
background-color: rgba(62, 127, 238, 0.7);
103+
color: #212529;
60104
}
61105

62106
.assessmentBubble5_3 {
63-
color: rgba(62, 127, 238, 1);
107+
background-color: rgba(62, 127, 238, 1);
108+
color: #000;
64109
}
65110

66111
.assessmentBubble5_4 {
67-
color: rgba(0, 44, 175, 0.8);
112+
background-color: rgba(0, 44, 175, 0.8);
113+
color: #fff;
68114
}
69115

70116
.assessmentBubble5_5 {
71-
color: rgba(11, 3, 139, 1);
117+
background-color: rgba(11, 3, 139, 1);
118+
color: #fff;
72119
}
73120

74121
.assessmentBubbleCloser5_1 {
@@ -91,6 +138,42 @@
91138
color: rgba(2, 40, 150, 1);
92139
}
93140

141+
.levelChip {
142+
display: inline-block;
143+
padding: 0.125rem 0.625rem;
144+
border-radius: 0.75rem;
145+
font-size: 0.75rem;
146+
font-weight: 600;
147+
line-height: 1.25;
148+
white-space: nowrap;
149+
background-color: #adb5bd;
150+
color: #212529;
151+
}
152+
.levelChip--1 {
153+
background-color: rgba(62, 127, 238, 0.3);
154+
color: #212529;
155+
}
156+
.levelChip--2 {
157+
background-color: rgba(62, 127, 238, 0.7);
158+
color: #212529;
159+
}
160+
.levelChip--3 {
161+
background-color: rgba(62, 127, 238, 1);
162+
color: #000;
163+
}
164+
.levelChip--4 {
165+
background-color: rgba(0, 44, 175, 0.8);
166+
color: #fff;
167+
}
168+
.levelChip--5 {
169+
background-color: rgba(11, 3, 139, 1);
170+
color: #fff;
171+
}
172+
173+
.assessmentComment {
174+
font-style: italic;
175+
}
176+
94177
/*
95178
.assessmentbubble.ab5_1 {
96179
background-color: rgb(169, 208, 255)
@@ -105,6 +188,6 @@
105188
background-color: rgb(42, 60, 152)
106189
}
107190
.assessmentbubble.ab5_5 {
108-
background-color: rgb(0, 11, 113)
191+
background-color: rgb(0, 11, 113)
109192
}
110193
*/

0 commit comments

Comments
 (0)