From ba7e84080972809b317f5b68dd362033bb9e2cbf Mon Sep 17 00:00:00 2001 From: Abhishek-Punhani Date: Mon, 17 Aug 2026 22:35:24 +0530 Subject: [PATCH] feat: introduce ClickableRegion component to improve keyboard accessibility for interaction editors Signed-off-by: Abhishek-Punhani --- .../__tests__/ClickableRegion.spec.js | 69 +++++++++++++ .../components/ClickableRegion/index.vue | 96 +++++++++++++++++++ .../choice/ChoiceInteractionEditor.vue | 43 +++++++-- .../__tests__/ChoiceInteractionEditor.spec.js | 26 +++++ .../ordering/OrderingInteractionEditor.vue | 38 ++++++-- .../textEntry/TextEntryEditor.vue | 26 +++-- .../views/QTIEditor/qtiEditorStrings.js | 8 ++ 7 files changed, 279 insertions(+), 27 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ClickableRegion/__tests__/ClickableRegion.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ClickableRegion/index.vue 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" > -
{{ 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" > -
{{ 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',