Skip to content

Commit 1993eba

Browse files
authored
Merge pull request #14374 from rajeshuchil/fix-qti-accessibility
fix(qti-viewer): accessibility and maxChoices fixes for choice and text-entry interactions
2 parents b16151b + 6eab1f0 commit 1993eba

5 files changed

Lines changed: 167 additions & 23 deletions

File tree

kolibri/plugins/qti_viewer/frontend/components/QTIViewer.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
components: {
2828
AssessmentItem,
2929
},
30+
inheritAttrs: false,
3031
setup(props, context) {
3132
const { defaultFile, reportLoadingError } = useContentViewer(props, context);
3233
const packageLoading = ref(true);

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

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,37 @@
33
import get from 'lodash/get';
44
import shuffled from 'kolibri-common/utils/shuffled';
55
import { computed, h, inject, provide } from 'vue';
6+
import { createTranslator } from 'kolibri/utils/i18n';
67
import { BooleanProp, NonNegativeIntProp, QTIIdentifierProp } from '../../utils/props';
78
import useTypedProps from '../../composables/useTypedProps';
89
10+
const strings = createTranslator('ChoiceInteractionStrings', {
11+
choiceListLabel: {
12+
message: 'Answer choices',
13+
context: 'Accessible label for the list of answer choices in an assessment question',
14+
},
15+
});
16+
17+
const { choiceListLabel$ } = strings;
18+
919
function getComponentTag(vnode) {
1020
return get(vnode, ['componentOptions', 'Ctor', 'extendOptions', 'tag']);
1121
}
1222
23+
/**
24+
* Safely normalizes a response value to an array.
25+
* Handles null, undefined, scalars, and arrays uniformly.
26+
*/
27+
function getSelectionsArray(value) {
28+
if (value === null || value === undefined) {
29+
return [];
30+
}
31+
if (Array.isArray(value)) {
32+
return value;
33+
}
34+
return [value];
35+
}
36+
1337
export default {
1438
name: 'QtiChoiceInteraction',
1539
tag: 'qti-choice-interaction',
@@ -29,13 +53,10 @@
2953
3054
const isSelected = identifier => {
3155
const variable = responses[typedProps.responseIdentifier.value];
32-
if (!variable.value) {
56+
if (!variable) {
3357
return false;
3458
}
35-
if (multiSelectable.value) {
36-
return variable.value.includes(identifier);
37-
}
38-
return variable.value === identifier;
59+
return getSelectionsArray(variable.value).includes(identifier);
3960
};
4061
4162
const toggleSelection = identifier => {
@@ -44,15 +65,27 @@
4465
}
4566
const currentlySelected = isSelected(identifier);
4667
const variable = responses[typedProps.responseIdentifier.value];
68+
if (!variable) {
69+
return false;
70+
}
4771
4872
if (currentlySelected) {
49-
variable.value = multiSelectable.value
50-
? variable.value.filter(v => v !== identifier)
51-
: null;
73+
if (multiSelectable.value) {
74+
variable.value = getSelectionsArray(variable.value).filter(v => v !== identifier);
75+
} else {
76+
variable.value = null;
77+
}
5278
} else {
53-
variable.value = multiSelectable.value
54-
? [...(variable.value || []), identifier]
55-
: identifier;
79+
if (multiSelectable.value) {
80+
const maxChoices = typedProps.maxChoices.value;
81+
const currentSelections = getSelectionsArray(variable.value);
82+
if (maxChoices > 0 && currentSelections.length >= maxChoices) {
83+
return false;
84+
}
85+
variable.value = [...currentSelections, identifier];
86+
} else {
87+
variable.value = identifier;
88+
}
5689
}
5790
5891
return true;
@@ -63,7 +96,7 @@
6396
provide('toggleSelection', toggleSelection);
6497
6598
const getShuffledOrder = choices => {
66-
if (!typedProps.shuffle) {
99+
if (!typedProps.shuffle.value) {
67100
return choices;
68101
}
69102
@@ -113,9 +146,11 @@
113146
'ul',
114147
{
115148
attrs: {
149+
role: 'listbox',
150+
'aria-label': choiceListLabel$(),
116151
'aria-multiselectable': multiSelectable.value,
117-
class: (attrs.class || '') + ' qti-choice-interaction',
118152
},
153+
class: [attrs.class || '', 'qti-choice-interaction'],
119154
},
120155
orderedChoices.map(choice => choice.vnode),
121156
);

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

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@
33
<li
44
class="qti-simple-choice"
55
role="option"
6-
:class="
6+
tabindex="0"
7+
:class="[
78
$computedClass({
89
'::before': {
910
border: `2px solid ${selected ? $themeTokens.textInverted : $themeTokens.annotation}`,
1011
},
11-
})
12-
"
12+
':focus': coreOutline,
13+
}),
14+
]"
1315
:aria-selected="selected"
14-
:style="extraStyles"
16+
:style="[extraStyles]"
1517
@click="handleClick"
1618
@keydown.enter="handleClick"
1719
@keydown.space.prevent="handleClick"
@@ -25,10 +27,11 @@
2527
<script>
2628
2729
import { computed, inject } from 'vue';
28-
import { themeTokens } from 'kolibri-design-system/lib/styles/theme';
30+
import { themeTokens, themeOutlineStyle } from 'kolibri-design-system/lib/styles/theme';
2931
import { BooleanProp, QTIIdentifierProp } from '../../utils/props';
3032
3133
const $themeTokens = themeTokens();
34+
const coreOutline = themeOutlineStyle();
3235
3336
export default {
3437
name: 'SimpleChoice',
@@ -56,6 +59,8 @@
5659
});
5760
5861
return {
62+
$themeTokens,
63+
coreOutline,
5964
selected,
6065
handleClick,
6166
extraStyles,

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

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,21 @@
33
<input
44
v-if="interactive"
55
v-model="variable"
6-
class="qti-text-entry-interaction"
6+
v-bind="inputAttrs"
7+
:class="['qti-text-entry-interaction', attrsClass, $computedClass({ ':focus': coreOutline })]"
8+
:aria-label="textEntryLabel$()"
79
:placeholder="placeholder"
810
:style="{
911
minWidth: `${Math.min(expectedLength ?? 20, 20)}ch`,
1012
maxWidth: '90%',
13+
border: `1px solid ${$themeTokens.fineLine}`,
1114
}"
1215
:type="inputType"
16+
autocomplete="off"
1317
>
1418
<div
1519
v-else
16-
class="qti-text-entry-interaction qti-text-entry-interaction-report"
20+
:class="['qti-text-entry-interaction', 'qti-text-entry-interaction-report', attrsClass]"
1721
>
1822
{{ variable || placeholder }}
1923
</div>
@@ -24,6 +28,8 @@
2428
<script>
2529
2630
import { computed, inject } from 'vue';
31+
import { themeTokens, themeOutlineStyle } from 'kolibri-design-system/lib/styles/theme';
32+
import { createTranslator } from 'kolibri/utils/i18n';
2733
import useTypedProps from '../../composables/useTypedProps';
2834
import {
2935
NumberProp,
@@ -34,14 +40,93 @@
3440
} from '../../utils/props';
3541
import { BASE_TYPE } from '../../constants';
3642
43+
const $themeTokens = themeTokens();
44+
45+
const strings = createTranslator('TextEntryInteractionStrings', {
46+
textEntryLabel: {
47+
message: 'Your answer',
48+
context: 'Accessible label for a text input field in an assessment question',
49+
},
50+
});
51+
52+
const { textEntryLabel$ } = strings;
53+
3754
export default {
3855
name: 'TextEntryInteraction',
3956
tag: 'qti-text-entry-interaction',
57+
inheritAttrs: false,
4058
41-
setup(props) {
42-
const responses = inject('responses');
59+
setup(props, context) {
60+
const responses = inject('responses', {});
4361
const typedProps = useTypedProps(props);
44-
const interactive = inject('interactive');
62+
const interactive = inject('interactive', true);
63+
64+
const getContextAttrs = () => {
65+
if (!context || !context.attrs) {
66+
return {};
67+
}
68+
return context.attrs;
69+
};
70+
71+
const ALLOWED_INPUT_ATTRS = new Set([
72+
'id',
73+
'name',
74+
'value',
75+
'disabled',
76+
'readonly',
77+
'required',
78+
'min',
79+
'max',
80+
'step',
81+
'minlength',
82+
'maxlength',
83+
'inputmode',
84+
'spellcheck',
85+
'autocapitalize',
86+
'autocorrect',
87+
'enterkeyhint',
88+
'tabindex',
89+
'title',
90+
'lang',
91+
'dir',
92+
'autofocus',
93+
'list',
94+
]);
95+
96+
const isPrimitiveAttrValue = value => {
97+
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
98+
};
99+
100+
const isAriaOrDataAttr = name => {
101+
return name.startsWith('aria-') || name.startsWith('data-');
102+
};
103+
104+
const inputAttrs = computed(() => {
105+
return Object.entries(getContextAttrs()).reduce((forwardedAttrs, [name, value]) => {
106+
if (name === 'class') {
107+
return forwardedAttrs;
108+
}
109+
if (name === 'style') {
110+
forwardedAttrs[name] = value;
111+
return forwardedAttrs;
112+
}
113+
if (name === 'placeholder-text') {
114+
return forwardedAttrs;
115+
}
116+
if (name === 'placeholder' || name === 'type' || name === 'aria-label') {
117+
return forwardedAttrs;
118+
}
119+
if (!isPrimitiveAttrValue(value)) {
120+
return forwardedAttrs;
121+
}
122+
if (ALLOWED_INPUT_ATTRS.has(name) || isAriaOrDataAttr(name)) {
123+
forwardedAttrs[name] = value;
124+
}
125+
return forwardedAttrs;
126+
}, {});
127+
});
128+
129+
const attrsClass = computed(() => getContextAttrs().class);
45130
46131
const inputDeclaration = computed(() => {
47132
return responses[typedProps.responseIdentifier.value];
@@ -65,10 +150,15 @@
65150
});
66151
67152
return {
153+
$themeTokens,
154+
textEntryLabel$,
68155
variable,
69156
placeholder: typedProps.placeholderText,
70157
interactive,
71158
inputType,
159+
coreOutline: themeOutlineStyle(),
160+
inputAttrs,
161+
attrsClass,
72162
};
73163
},
74164
props: {
@@ -89,6 +179,11 @@
89179
90180
<style scoped>
91181
182+
.qti-text-entry-interaction {
183+
padding: 4px 8px;
184+
border-radius: 4px;
185+
}
186+
92187
.qti-text-entry-interaction-report {
93188
box-sizing: border-box;
94189
width: 100%;

packages/kolibri/components/internal/ContentViewer/index.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,18 @@ export default {
4747
);
4848
}
4949

50+
const safeAttrs = {};
51+
for (const [key, value] of Object.entries(context.data.attrs || {})) {
52+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
53+
safeAttrs[key] = value;
54+
}
55+
}
56+
5057
return createElement(
5158
defaultItemPreset + VIEWER_SUFFIX,
5259
{
5360
...context.data,
61+
attrs: safeAttrs,
5462
props: context.props,
5563
on: combinedListeners,
5664
},

0 commit comments

Comments
 (0)