Skip to content

Commit 8a699c5

Browse files
committed
fix: accessibility and maxChoices fixes for QTI interactions (#14347)
1 parent 3de457a commit 8a699c5

File tree

3 files changed

+196
-19
lines changed

3 files changed

+196
-19
lines changed

kolibri/plugins/qti_viewer/frontend/components/interactions/ChoiceInteraction.vue

Lines changed: 163 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,36 @@
11
<script>
22
33
import get from 'lodash/get';
4+
import isArray from 'lodash/isArray';
45
import shuffled from 'kolibri-common/utils/shuffled';
5-
import { computed, h, inject, provide } from 'vue';
6+
import { computed, getCurrentInstance, h, inject, nextTick, provide, ref, shallowRef, watch } from 'vue';
67
import { BooleanProp, NonNegativeIntProp, QTIIdentifierProp } from '../../utils/props';
78
import useTypedProps from '../../composables/useTypedProps';
89
910
function getComponentTag(vnode) {
1011
return get(vnode, ['componentOptions', 'Ctor', 'extendOptions', 'tag']);
1112
}
1213
14+
/**
15+
* Safely normalizes a response value to an array.
16+
* Handles null, undefined, scalars, and arrays uniformly.
17+
*/
18+
function getSelectionsArray(value) {
19+
if (value === null || value === undefined) {
20+
return [];
21+
}
22+
if (isArray(value)) {
23+
return value;
24+
}
25+
return [value];
26+
}
27+
1328
export default {
1429
name: 'QtiChoiceInteraction',
1530
tag: 'qti-choice-interaction',
1631
1732
setup(props, { slots, attrs }) {
33+
const { proxy } = getCurrentInstance();
1834
const responses = inject('responses');
1935
2036
const QTI_CONTEXT = inject('QTI_CONTEXT');
@@ -27,43 +43,144 @@
2743
return typedProps.maxChoices.value !== 1;
2844
});
2945
30-
const isSelected = identifier => {
46+
// shallowRef wrapper so computeds re-evaluate when the underlying
47+
// QTIVariable is replaced (responses object is not reactive).
48+
const trackedVariable = shallowRef(null);
49+
const selectionVersion = ref(0);
50+
51+
function syncTrackedVariable() {
3152
const variable = responses[typedProps.responseIdentifier.value];
32-
if (!variable.value) {
33-
return false;
53+
if (trackedVariable.value !== variable) {
54+
trackedVariable.value = variable || null;
3455
}
35-
if (multiSelectable.value) {
36-
return variable.value.includes(identifier);
56+
}
57+
58+
const isSelected = identifier => {
59+
const variable = trackedVariable.value;
60+
// eslint-disable-next-line no-unused-expressions
61+
selectionVersion.value;
62+
if (!variable || variable.value === null || variable.value === undefined) {
63+
return false;
3764
}
38-
return variable.value === identifier;
65+
return getSelectionsArray(variable.value).includes(identifier);
3966
};
4067
4168
const toggleSelection = identifier => {
4269
if (!interactive.value) {
4370
return;
4471
}
72+
syncTrackedVariable();
4573
const currentlySelected = isSelected(identifier);
46-
const variable = responses[typedProps.responseIdentifier.value];
74+
const variable = trackedVariable.value;
75+
if (!variable) {
76+
return false;
77+
}
4778
4879
if (currentlySelected) {
49-
variable.value = multiSelectable.value
50-
? variable.value.filter(v => v !== identifier)
51-
: null;
80+
if (multiSelectable.value) {
81+
variable.value = getSelectionsArray(variable.value).filter(v => v !== identifier);
82+
} else {
83+
variable.value = null;
84+
}
5285
} else {
53-
variable.value = multiSelectable.value
54-
? [...(variable.value || []), identifier]
55-
: identifier;
86+
if (multiSelectable.value) {
87+
const maxChoices = typedProps.maxChoices.value;
88+
const currentSelections = getSelectionsArray(variable.value);
89+
if (maxChoices > 0 && currentSelections.length >= maxChoices) {
90+
return false;
91+
}
92+
variable.value = [...currentSelections, identifier];
93+
} else {
94+
variable.value = identifier;
95+
}
5696
}
5797
98+
selectionVersion.value++;
5899
return true;
59100
};
60101
61-
// Provide functions to child components
102+
// When maxChoices changes (e.g. sandbox XML editing), trim excess
103+
// selections so the constraint is immediately enforced.
104+
watch(
105+
() => typedProps.maxChoices.value,
106+
newMax => {
107+
syncTrackedVariable();
108+
const variable = trackedVariable.value;
109+
if (!variable) {
110+
return;
111+
}
112+
const selections = getSelectionsArray(variable.value);
113+
if (newMax > 0 && selections.length > newMax) {
114+
variable.value = multiSelectable.value
115+
? selections.slice(0, newMax)
116+
: selections[0] || null;
117+
selectionVersion.value++;
118+
}
119+
},
120+
);
121+
122+
// Roving tabindex: only one option has tabindex="0" at a time;
123+
// the rest get tabindex="-1". Arrow keys move focus between options.
124+
const focusedIndex = ref(0);
125+
// Ordered list of identifiers, updated each render via nextTick.
126+
// Used by isFocusTarget and setFocusedIndex provided to children.
127+
const orderedIdentifiers = ref([]);
128+
129+
function handleListKeydown(event) {
130+
const count = orderedIdentifiers.value.length;
131+
if (count === 0) {
132+
return;
133+
}
134+
const { key } = event;
135+
let newIndex = focusedIndex.value;
136+
switch (key) {
137+
case 'ArrowDown':
138+
newIndex = (newIndex + 1) % count;
139+
break;
140+
case 'ArrowUp':
141+
newIndex = (newIndex - 1 + count) % count;
142+
break;
143+
case 'Home':
144+
newIndex = 0;
145+
break;
146+
case 'End':
147+
newIndex = count - 1;
148+
break;
149+
default:
150+
// Don't prevent default for keys we don't handle
151+
return;
152+
}
153+
event.preventDefault();
154+
focusedIndex.value = newIndex;
155+
const listEl = event.currentTarget;
156+
const options = listEl.querySelectorAll('[role="option"]');
157+
if (options[newIndex]) {
158+
options[newIndex].focus();
159+
}
160+
}
161+
162+
// Provide functions to child components (SimpleChoice).
163+
// NOTE: Only plain functions work via provide/inject here, NOT refs.
164+
// SafeHTML (functional component) creates SimpleChoice vnodes in its
165+
// own render scope, so refs provided here are invisible to SimpleChoice.
166+
// Functions work because Vue 2 resolves inject values up through the
167+
// _provided chain, which includes ChoiceInteraction regardless of how
168+
// the vnode was created.
62169
provide('isSelected', isSelected);
63170
provide('toggleSelection', toggleSelection);
171+
provide('isFocusTarget', identifier => {
172+
const idx = orderedIdentifiers.value.indexOf(identifier);
173+
return idx >= 0 && idx === focusedIndex.value;
174+
});
175+
provide('setFocusedIndex', identifier => {
176+
const idx = orderedIdentifiers.value.indexOf(identifier);
177+
if (idx >= 0) {
178+
focusedIndex.value = idx;
179+
}
180+
});
64181
65182
const getShuffledOrder = choices => {
66-
if (!typedProps.shuffle) {
183+
if (!typedProps.shuffle.value) {
67184
return choices;
68185
}
69186
@@ -85,8 +202,8 @@
85202
return result;
86203
};
87204
88-
// Return render function
89205
return () => {
206+
syncTrackedVariable();
90207
const allContent = slots.default();
91208
const nonChoiceContent = allContent.filter(
92209
vnode => getComponentTag(vnode) !== 'qti-simple-choice',
@@ -109,18 +226,39 @@
109226
// Get shuffled order (or original if shuffle=false)
110227
const orderedChoices = getShuffledOrder(choices);
111228
229+
// Keep orderedIdentifiers in sync so that provided functions
230+
// (isFocusTarget, setFocusedIndex) can map identifier -> index.
231+
// Use nextTick to avoid mutating reactive state during render,
232+
// which would trigger an infinite re-render loop in Vue 2.
233+
const ids = orderedChoices.map(c => c.identifier);
234+
const idsChanged =
235+
ids.length !== orderedIdentifiers.value.length ||
236+
ids.some((id, i) => id !== orderedIdentifiers.value[i]);
237+
if (idsChanged || focusedIndex.value >= ids.length) {
238+
nextTick(() => {
239+
orderedIdentifiers.value = ids;
240+
if (focusedIndex.value >= ids.length) {
241+
focusedIndex.value = Math.max(0, ids.length - 1);
242+
}
243+
});
244+
}
245+
112246
const choicesList = h(
113247
'ul',
114248
{
115249
attrs: {
250+
role: 'listbox',
251+
'aria-label': proxy.$tr('choiceListLabel'),
116252
'aria-multiselectable': multiSelectable.value,
117-
class: (attrs.class || '') + ' qti-choice-interaction',
253+
},
254+
class: [(attrs.class || ''), 'qti-choice-interaction'],
255+
on: {
256+
keydown: handleListKeydown,
118257
},
119258
},
120259
orderedChoices.map(choice => choice.vnode),
121260
);
122261
123-
// Create container with non-choice content first, then choices list
124262
return h('div', [...nonChoiceContent, choicesList]);
125263
};
126264
},
@@ -136,6 +274,12 @@
136274
},
137275
/* eslint-enable */
138276
},
277+
$trs: {
278+
choiceListLabel: {
279+
message: 'Answer choices',
280+
context: 'Accessible label for the list of answer choices in an assessment question',
281+
},
282+
},
139283
};
140284
141285
</script>

kolibri/plugins/qti_viewer/frontend/components/interactions/SimpleChoice.vue

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,21 @@
33
<li
44
class="qti-simple-choice"
55
role="option"
6+
:tabindex="isFocused ? 0 : -1"
67
:class="
78
$computedClass({
89
'::before': {
910
border: `2px solid ${selected ? $themeTokens.textInverted : $themeTokens.annotation}`,
1011
},
12+
':focus': $coreOutline,
1113
})
1214
"
1315
:aria-selected="selected"
1416
:style="extraStyles"
1517
@click="handleClick"
1618
@keydown.enter="handleClick"
1719
@keydown.space.prevent="handleClick"
20+
@focus="handleFocus"
1821
>
1922
<slot></slot>
2023
</li>
@@ -37,13 +40,27 @@
3740
setup(props) {
3841
const isSelected = inject('isSelected');
3942
const toggleSelection = inject('toggleSelection');
43+
const isFocusTargetFn = inject('isFocusTarget');
44+
const setFocusedIndex = inject('setFocusedIndex');
4045
4146
const handleClick = () => {
4247
toggleSelection(props.identifier);
4348
};
4449
50+
// When this option receives focus (e.g. via mouse click or keyboard),
51+
// sync the parent's focusedIndex to stay consistent.
52+
const handleFocus = () => {
53+
if (setFocusedIndex) {
54+
setFocusedIndex(props.identifier);
55+
}
56+
};
57+
4558
const selected = computed(() => isSelected(props.identifier));
4659
60+
const isFocused = computed(() => {
61+
return isFocusTargetFn ? isFocusTargetFn(props.identifier) : true;
62+
});
63+
4764
const extraStyles = computed(() => {
4865
if (!selected.value) {
4966
return {};
@@ -57,7 +74,9 @@
5774
5875
return {
5976
selected,
77+
isFocused,
6078
handleClick,
79+
handleFocus,
6180
extraStyles,
6281
};
6382
},

kolibri/plugins/qti_viewer/frontend/components/interactions/TextEntryInteraction.vue

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44
v-if="interactive"
55
v-model="variable"
66
class="qti-text-entry-interaction"
7+
:aria-label="`${$tr('textEntryLabel')} ${responseIdentifier}`"
78
:placeholder="placeholder"
89
:style="{
910
minWidth: `${Math.min(expectedLength ?? 20, 20)}ch`,
1011
maxWidth: '90%',
1112
}"
1213
:type="inputType"
14+
autocomplete="off"
1315
>
1416
<div
1517
v-else
@@ -82,13 +84,25 @@
8284
format: FormatProp(false),
8385
/* eslint-enable */
8486
},
87+
$trs: {
88+
textEntryLabel: {
89+
message: 'Text entry',
90+
context: 'Accessible label for a text input field in an assessment question',
91+
},
92+
},
8593
};
8694
8795
</script>
8896
8997
9098
<style scoped>
9199
100+
.qti-text-entry-interaction {
101+
padding: 4px 8px;
102+
border: 1px solid;
103+
border-radius: 4px;
104+
}
105+
92106
.qti-text-entry-interaction-report {
93107
box-sizing: border-box;
94108
width: 100%;

0 commit comments

Comments
 (0)