diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ClickableRegion/__tests__/ClickableRegion.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ClickableRegion/__tests__/ClickableRegion.spec.js
new file mode 100644
index 0000000000..63424501ff
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ClickableRegion/__tests__/ClickableRegion.spec.js
@@ -0,0 +1,69 @@
+import { render, screen, fireEvent } from '@testing-library/vue';
+import VueRouter from 'vue-router';
+import ClickableRegion from '../index.vue';
+
+describe('ClickableRegion', () => {
+ it('renders a button with the given aria-label', () => {
+ render(ClickableRegion, {
+ props: {
+ ariaLabel: 'Test label',
+ },
+ routes: new VueRouter(),
+ });
+
+ expect(screen.getByRole('button', { name: 'Test label' })).toBeInTheDocument();
+ });
+
+ it('does not render the button when suppressed is true', () => {
+ render(ClickableRegion, {
+ props: {
+ ariaLabel: 'Test label',
+ suppressed: true,
+ },
+ routes: new VueRouter(),
+ });
+
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ });
+
+ it('emits a single click event on mouse click', async () => {
+ const { emitted } = render(ClickableRegion, {
+ props: {
+ ariaLabel: 'Test label',
+ },
+ routes: new VueRouter(),
+ });
+
+ await fireEvent.click(screen.getByRole('button'));
+
+ expect(emitted().click).toHaveLength(1);
+ });
+
+ it('emits a single click event on Enter key', async () => {
+ const { emitted } = render(ClickableRegion, {
+ props: {
+ ariaLabel: 'Test label',
+ },
+ routes: new VueRouter(),
+ });
+
+ const button = screen.getByRole('button');
+ await fireEvent.keyDown(button, { key: 'Enter', code: 'Enter' });
+
+ expect(emitted().click).toHaveLength(1);
+ });
+
+ it('emits a single click event on Space key', async () => {
+ const { emitted } = render(ClickableRegion, {
+ props: {
+ ariaLabel: 'Test label',
+ },
+ routes: new VueRouter(),
+ });
+
+ const button = screen.getByRole('button');
+ await fireEvent.keyDown(button, { key: ' ', code: 'Space' });
+
+ expect(emitted().click).toHaveLength(1);
+ });
+});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ClickableRegion/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ClickableRegion/index.vue
new file mode 100644
index 0000000000..ddc84d6754
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ClickableRegion/index.vue
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue
index bc1edad73d..e7fe42a35a 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue
@@ -27,9 +27,11 @@
-
@@ -42,6 +44,7 @@
:minHeight="'80px'"
:autofocus="mode === 'edit' && isQuestionOpen"
:imageProcessor="EditorImageProcessor"
+ :tabindex="isQuestionOpen ? 0 : -1"
class="editor"
@update="setPrompt"
@minimize="closeQuestion"
@@ -49,7 +52,7 @@
-
+
@@ -90,10 +93,12 @@
class="choice-group"
>
-
setChoiceContent(choice.id, html)"
@minimize="closeChoice"
@@ -181,7 +187,7 @@
>
{{ errorDuplicateChoiceContent$() }}
-
+
@@ -211,6 +217,7 @@
import CollapsibleToolbar from '../../components/CollapsibleToolbar/index.vue';
import ValidationMessage from '../../components/ValidationMessage/index.vue';
import AddListItemButton from '../../components/AddListItemButton/index.vue';
+ import ClickableRegion from '../../components/ClickableRegion/index.vue';
import AnswerSettings from './components/AnswerSettings/index.vue';
import TipTapEditor from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditor';
import EditorImageProcessor from 'shared/views/TipTapEditor/TipTapEditor/services/imageService';
@@ -219,6 +226,7 @@
name: 'ChoiceInteractionEditor',
components: {
+ ClickableRegion,
TipTapEditor,
CollapsibleToolbar,
ValidationMessage,
@@ -245,6 +253,8 @@
answersLabel$,
answersDescriptionSingleChoice$,
answersDescriptionMultipleChoice$,
+ editQuestionLabel$,
+ editAnswerOptionLabel$,
} = qtiEditorStrings;
const palette = themePalette();
@@ -280,9 +290,14 @@
function handlePromptClick(event) {
if (props.mode !== 'edit') return;
- if (event.target.closest('button') || event.target.closest('input')) return;
+ const closestBtn =
+ event.target && event.target.closest ? event.target.closest('button') : null;
+ if (closestBtn && !closestBtn.classList.contains('overlay-button')) return;
+ const closestInput =
+ event.target && event.target.closest ? event.target.closest('input') : null;
+ if (closestInput) return;
if (!isQuestionOpen.value) {
- event.stopPropagation();
+ if (event && event.stopPropagation) event.stopPropagation();
openQuestion();
}
}
@@ -290,8 +305,13 @@
function handleChoiceClick(event, choiceId) {
if (props.mode !== 'edit') return;
if (openChoiceId.value === choiceId) return;
- if (event.target.closest('button') || event.target.closest('input')) return;
- event.stopPropagation();
+ const closestBtn =
+ event.target && event.target.closest ? event.target.closest('button') : null;
+ if (closestBtn && !closestBtn.classList.contains('overlay-button')) return;
+ const closestInput =
+ event.target && event.target.closest ? event.target.closest('input') : null;
+ if (closestInput) return;
+ if (event && event.stopPropagation) event.stopPropagation();
openChoice(choiceId);
}
@@ -433,7 +453,7 @@
}
return {
borderColor: questionHasError.value ? tokens.error : tokens.fineLine,
- cursor: props.mode === 'edit' ? 'pointer' : undefined,
+ '--clickable-region-hover-bg': palette.blue.v_100,
};
});
@@ -475,9 +495,12 @@
borderColor = palette.green.v_500;
}
+ const hoverBg = isCorrect ? palette.green.v_100 : tokens.fineLine;
+
return {
borderColor,
backgroundColor: isCorrect ? palette.green.v_50 : null,
+ '--clickable-region-hover-bg': hoverBg,
};
}
@@ -524,6 +547,8 @@
errorEmptyChoiceContent$,
errorDuplicateChoiceContent$,
questionLabel$,
+ editQuestionLabel$,
+ editAnswerOptionLabel$,
};
},
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionEditor.spec.js
index 7cf59d9378..ddd504d4f7 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionEditor.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionEditor.spec.js
@@ -232,6 +232,32 @@ describe('ChoiceInteractionEditor', () => {
await fireEvent.click(deleteBtns[0]);
expect(screen.getAllByRole('radio')).toHaveLength(2);
});
+
+ it('opens the prompt for editing via keyboard (Enter)', async () => {
+ renderEditor({
+ interaction: block(CHOICE_SINGLE_SELECT_XML),
+ questionType: QuestionType.SINGLE_SELECT,
+ });
+ const promptBtn = screen.getByRole('button', { name: tr.$tr('editQuestionLabel') });
+ await fireEvent.keyDown(promptBtn, { key: 'Enter', code: 'Enter' });
+ expect(
+ screen.queryByRole('button', { name: tr.$tr('editQuestionLabel') }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('opens a choice for editing via keyboard (Space)', async () => {
+ renderEditor({
+ interaction: block(CHOICE_SINGLE_SELECT_XML),
+ questionType: QuestionType.SINGLE_SELECT,
+ });
+ const choiceBtn = screen.getByRole('button', {
+ name: tr.$tr('editAnswerOptionLabel', { number: 2 }),
+ });
+ await fireEvent.keyDown(choiceBtn, { key: ' ', code: 'Space' });
+ expect(
+ screen.queryByRole('button', { name: tr.$tr('editAnswerOptionLabel', { number: 2 }) }),
+ ).not.toBeInTheDocument();
+ });
});
describe('view mode', () => {
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionEditor.vue
index cc1a74d44f..bbc9a4233e 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionEditor.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionEditor.vue
@@ -12,9 +12,11 @@
>
{{ questionLabel$() }}
-
-
+
@@ -73,10 +76,12 @@
class="item-group"
>
-
setItemContent(item.id, html)"
@minimize="closeItem"
@@ -138,7 +144,7 @@
>
{{ errorDuplicateItemContent$() }}
-
+
@@ -165,6 +171,7 @@
import CollapsibleToolbar from '../../components/CollapsibleToolbar/index.vue';
import ValidationMessage from '../../components/ValidationMessage/index.vue';
import AddListItemButton from '../../components/AddListItemButton/index.vue';
+ import ClickableRegion from '../../components/ClickableRegion/index.vue';
import TipTapEditor from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditor';
import EditorImageProcessor from 'shared/views/TipTapEditor/TipTapEditor/services/imageService';
@@ -176,6 +183,7 @@
CollapsibleToolbar,
ValidationMessage,
AddListItemButton,
+ ClickableRegion,
},
setup(props, { emit }) {
@@ -194,6 +202,8 @@
errorTooFewChoices$,
errorEmptyItemContent$,
errorDuplicateItemContent$,
+ editQuestionLabel$,
+ editAnswerOptionLabel$,
} = qtiEditorStrings;
const questionTypeRef = computed(() => props.questionType);
@@ -234,9 +244,14 @@
function handlePromptClick(event) {
if (props.mode !== 'edit') return;
- if (event.target.closest('button') || event.target.closest('input')) return;
+ const closestBtn =
+ event.target && event.target.closest ? event.target.closest('button') : null;
+ if (closestBtn && !closestBtn.classList.contains('overlay-button')) return;
+ const closestInput =
+ event.target && event.target.closest ? event.target.closest('input') : null;
+ if (closestInput) return;
if (!isPromptOpen.value) {
- event.stopPropagation();
+ if (event && event.stopPropagation) event.stopPropagation();
openPrompt();
}
}
@@ -244,8 +259,13 @@
function handleItemClick(event, itemId) {
if (props.mode !== 'edit') return;
if (openItemId.value === itemId) return;
- if (event.target.closest('button') || event.target.closest('input')) return;
- event.stopPropagation();
+ const closestBtn =
+ event.target && event.target.closest ? event.target.closest('button') : null;
+ if (closestBtn && !closestBtn.classList.contains('overlay-button')) return;
+ const closestInput =
+ event.target && event.target.closest ? event.target.closest('input') : null;
+ if (closestInput) return;
+ if (event && event.stopPropagation) event.stopPropagation();
openItem(itemId);
}
@@ -404,6 +424,8 @@
errorTooFewChoices$,
errorEmptyItemContent$,
errorDuplicateItemContent$,
+ editQuestionLabel$,
+ editAnswerOptionLabel$,
};
},
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue
index 3f4967574a..7bb040cf10 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue
@@ -13,17 +13,14 @@
{{ questionLabel$() }}
-
-
+
@@ -185,13 +183,14 @@
import { useTextEntryInteraction } from '../../composables/useTextEntryInteraction';
import ValidationMessage from 'shared/views/QTIEditor/components/ValidationMessage';
import AddListItemButton from 'shared/views/QTIEditor/components/AddListItemButton';
+ import ClickableRegion from 'shared/views/QTIEditor/components/ClickableRegion';
import EditorImageProcessor from 'shared/views/TipTapEditor/TipTapEditor/services/imageService';
import TipTapEditor from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditor';
export default {
name: 'TextEntryEditor',
- components: { TipTapEditor, ValidationMessage, AddListItemButton },
+ components: { TipTapEditor, ValidationMessage, AddListItemButton, ClickableRegion },
inheritAttrs: false,
setup(props, { emit }) {
@@ -212,6 +211,7 @@
errorInvalidNumericValue$,
errorEmptyAnswerContent$,
errorDuplicateAnswerContent$,
+ editQuestionLabel$,
} = qtiEditorStrings;
const questionTypeRef = computed(() => props.questionType);
@@ -250,9 +250,14 @@
function handlePromptClick(event) {
if (props.mode !== 'edit') return;
- if (event.target.closest('button') || event.target.closest('input')) return;
+ const closestBtn =
+ event.target && event.target.closest ? event.target.closest('button') : null;
+ if (closestBtn && !closestBtn.classList.contains('overlay-button')) return;
+ const closestInput =
+ event.target && event.target.closest ? event.target.closest('input') : null;
+ if (closestInput) return;
if (!isPromptOpen.value) {
- event.stopPropagation();
+ if (event && event.stopPropagation) event.stopPropagation();
openPrompt();
}
}
@@ -380,6 +385,7 @@
errorInvalidNumericValue$,
errorEmptyAnswerContent$,
errorDuplicateAnswerContent$,
+ editQuestionLabel$,
ValidationError,
setAnswerInputRef,
focusedAnswerId,
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js
index 2100820126..f597368a2e 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js
@@ -139,6 +139,14 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', {
message: 'Add choice',
context: 'Button that appends a new answer choice',
},
+ editQuestionLabel: {
+ message: 'Edit question',
+ context: 'Accessible label for the clickable region to edit the question prompt',
+ },
+ editAnswerOptionLabel: {
+ message: 'Edit answer option {number}',
+ context: 'Accessible label for the clickable region to edit an answer choice',
+ },
deleteChoiceBtn: {
message: 'Delete choice',
context: 'Accessible label for the delete-choice icon button',