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" > -
{{ 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" > -
{{ 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',