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..e8e5589e38
--- /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.click(button);
+
+ 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.click(button);
+
+ 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..e678e1efa4
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ClickableRegion/index.vue
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
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..3ded7ec658 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="-1"
class="editor"
@update="setPrompt"
@minimize="closeQuestion"
@@ -49,7 +52,7 @@
-
+
@@ -90,11 +93,13 @@
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();
@@ -278,20 +288,16 @@
openChoiceId.value = null;
}
- function handlePromptClick(event) {
+ function handlePromptClick() {
if (props.mode !== 'edit') return;
- if (event.target.closest('button') || event.target.closest('input')) return;
if (!isQuestionOpen.value) {
- event.stopPropagation();
openQuestion();
}
}
- function handleChoiceClick(event, choiceId) {
+ function handleChoiceClick(choiceId) {
if (props.mode !== 'edit') return;
if (openChoiceId.value === choiceId) return;
- if (event.target.closest('button') || event.target.closest('input')) return;
- event.stopPropagation();
openChoice(choiceId);
}
@@ -325,7 +331,6 @@
{ immediate: true },
);
- // Emit bodyXml and responseDeclarations whenever either changes.
const workingInteraction = computed(() => ({
bodyXml: bodyXml.value,
responseDeclarations: responseDeclarations.value,
@@ -423,9 +428,21 @@
const isPromptEditing = computed(() => props.mode === 'edit' && isQuestionOpen.value);
- const promptWrapperClass = computed(() => {
- return isPromptEditing.value ? 'choice-editor__prompt-wrap' : 'choice-border';
- });
+ function getPromptWrapperClass() {
+ if (isPromptEditing.value) {
+ return 'choice-editor__prompt-wrap';
+ }
+ const clickable = props.mode === 'edit';
+ return [
+ 'choice-border',
+ { 'is-clickable': clickable },
+ clickable
+ ? instance.proxy.$computedClass({
+ ':hover': { backgroundColor: tokens.fineLine },
+ })
+ : '',
+ ];
+ }
const promptWrapperStyle = computed(() => {
if (isPromptEditing.value) {
@@ -433,7 +450,6 @@
}
return {
borderColor: questionHasError.value ? tokens.error : tokens.fineLine,
- cursor: props.mode === 'edit' ? 'pointer' : undefined,
};
});
@@ -475,9 +491,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,
};
}
@@ -485,7 +504,7 @@
return {
EditorImageProcessor,
- promptWrapperClass,
+ getPromptWrapperClass,
promptWrapperStyle,
state,
isSingleSelect,
@@ -524,6 +543,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..032cf2885e 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.click(promptBtn);
+ 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.click(choiceBtn);
+ 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..6d400f9366 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,11 +76,13 @@
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);
@@ -232,20 +242,16 @@
openItemId.value = null;
}
- function handlePromptClick(event) {
+ function handlePromptClick() {
if (props.mode !== 'edit') return;
- if (event.target.closest('button') || event.target.closest('input')) return;
if (!isPromptOpen.value) {
- event.stopPropagation();
openPrompt();
}
}
- function handleItemClick(event, itemId) {
+ function handleItemClick(itemId) {
if (props.mode !== 'edit') return;
if (openItemId.value === itemId) return;
- if (event.target.closest('button') || event.target.closest('input')) return;
- event.stopPropagation();
openItem(itemId);
}
@@ -307,15 +313,17 @@
const isPromptEditing = computed(() => props.mode === 'edit' && isPromptOpen.value);
- const promptWrapperClass = computed(() =>
- isPromptEditing.value ? 'prompt-wrapper' : 'item-border',
- );
+ const promptWrapperClass = computed(() => {
+ if (isPromptEditing.value) {
+ return 'prompt-wrapper';
+ }
+ return ['item-border', { 'is-clickable': props.mode === 'edit' }];
+ });
const promptWrapperStyle = computed(() => {
if (isPromptEditing.value) return {};
return {
borderColor: promptHasError.value ? tokens.error : tokens.fineLine,
- cursor: props.mode === 'edit' ? 'pointer' : undefined,
};
});
@@ -404,6 +412,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..4eb1a85120 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,17 @@
{{ questionLabel$() }}
-
-
+
@@ -185,13 +186,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 +214,7 @@
errorInvalidNumericValue$,
errorEmptyAnswerContent$,
errorDuplicateAnswerContent$,
+ editQuestionLabel$,
} = qtiEditorStrings;
const questionTypeRef = computed(() => props.questionType);
@@ -248,11 +251,9 @@
runValidation();
}
- function handlePromptClick(event) {
+ function handlePromptClick() {
if (props.mode !== 'edit') return;
- if (event.target.closest('button') || event.target.closest('input')) return;
if (!isPromptOpen.value) {
- event.stopPropagation();
openPrompt();
}
}
@@ -380,6 +381,7 @@
errorInvalidNumericValue$,
errorEmptyAnswerContent$,
errorDuplicateAnswerContent$,
+ editQuestionLabel$,
ValidationError,
setAnswerInputRef,
focusedAnswerId,
@@ -456,6 +458,14 @@
&.has-error {
border-color: v-bind('$themeTokens.error');
}
+
+ &.is-clickable {
+ cursor: pointer;
+
+ &:hover {
+ background-color: v-bind('$themeTokens.fineLine');
+ }
+ }
}
/* Prompt card when open */
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',