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 1673a4964d..0ed605d5ca 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue @@ -81,153 +81,161 @@ {{ answersDescription }} - - 's list role, so grouping sits on this wrapper. + `radiogroup` deliberately restates KRadioButtonGroup's own role: binding `undefined` + instead makes Vue strip the child's role on a later re-render. --> + - - +
    -
    - - -
    +
  1. + +
    - - +
    + + +
    + +
    +
    + +
    - + + +
    - - - -
    - - - - -
    -
    - -
    +
    + +
    -
    - +
    + +
    -
    - - - {{ errorEmptyChoiceContent$() }} - - - {{ errorDuplicateChoiceContent$() }} - -
    -
  2. - - - + + + {{ errorEmptyChoiceContent$() }} + + + {{ errorDuplicateChoiceContent$() }} + +
    + + +
+
+ props.mode === 'edit' && !state.value.shuffle); - const listTag = computed(() => (isSingleSelect.value ? 'KRadioButtonGroup' : 'div')); + const listWrapperTag = computed(() => (isSingleSelect.value ? 'KRadioButtonGroup' : 'div')); const errorCodes = computed(() => errors.value.map(e => e.code)); const emptyChoiceIds = computed( @@ -525,7 +533,7 @@ correctChoiceId, isOnlyChoice, isReorderable, - listTag, + listWrapperTag, questionHasError, noCorrectAnswerError, tooManyCorrectError, @@ -630,6 +638,7 @@ gap: 4px; padding: 0; margin: 0; + list-style: none; } /* Groups the bordered card with its per-choice error messages */ 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 c555b3f423..d7beebbf50 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 @@ -2,6 +2,7 @@ import { render, screen, fireEvent, within } from '@testing-library/vue'; import userEvent from '@testing-library/user-event'; import { nextTick } from 'vue'; import VueRouter from 'vue-router'; +import useKLiveRegion from 'kolibri-design-system/lib/composables/useKLiveRegion'; import ChoiceInteractionEditor from '../ChoiceInteractionEditor.vue'; import { @@ -35,11 +36,17 @@ jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { default: () => ({ windowIsSmall: ref(false) }), }; }); +// `useDraggableUniverse` destructures this composable too, so the automock has to return +// a usable object rather than undefined. +jest.mock('kolibri-design-system/lib/composables/useKLiveRegion'); let teleportContainer; +let sendPoliteMessage; beforeEach(() => { mockSortableInstances = []; + sendPoliteMessage = jest.fn(); + useKLiveRegion.mockReturnValue({ sendPoliteMessage }); teleportContainer = document.createElement('div'); teleportContainer.id = 'test-settings-target'; document.body.appendChild(teleportContainer); @@ -208,7 +215,6 @@ describe('ChoiceInteractionEditor', () => { questionType: QuestionType.SINGLE_SELECT, }); await fireEvent.click(screen.getByRole('button', { name: tr.$tr('addChoiceBtn') })); - // 3 original choices + 1 newly added = 4 radios (choice list uses divs, not li elements) expect(screen.getAllByRole('radio')).toHaveLength(4); }); @@ -292,6 +298,43 @@ describe('ChoiceInteractionEditor', () => { ); }); + it('announces the moved choice and its new position', async () => { + const user = userEvent.setup(); + renderEditor({ + interaction: block(CHOICE_SINGLE_SELECT_XML), + questionType: QuestionType.SINGLE_SELECT, + }); + await user.click(screen.getByRole('button', { name: moveDownName(1) })); + + expect(sendPoliteMessage).toHaveBeenCalledWith( + dragTr.$tr('itemMovedToPosition', { item: choiceLabel(1), position: 2, total: 3 }), + ); + }); + + it('keeps focus on the move-down button of the choice that moved', async () => { + const user = userEvent.setup(); + renderEditor({ + interaction: block(CHOICE_SINGLE_SELECT_XML), + questionType: QuestionType.SINGLE_SELECT, + }); + await user.click(screen.getByRole('button', { name: moveDownName(1) })); + + // The moved choice is now second, so its widget is the one labelled for position 2. + expect(screen.getByRole('button', { name: moveDownName(2) })).toHaveFocus(); + }); + + it('moves focus to the move-up button when a choice lands last', async () => { + const user = userEvent.setup(); + renderEditor({ + interaction: block(CHOICE_SINGLE_SELECT_XML), + questionType: QuestionType.SINGLE_SELECT, + }); + await user.click(screen.getByRole('button', { name: moveDownName(2) })); + + // Last position hides move-down, so focus has to fall back to move-up. + expect(screen.getByRole('button', { name: moveUpName(3) })).toHaveFocus(); + }); + it('disables delete when only one choice remains', async () => { const xml = ` Only @@ -572,6 +615,43 @@ describe('ChoiceInteractionEditor', () => { screen.getAllByRole('checkbox').forEach(c => expect(c).toHaveAccessibleName()); }); + it('renders the options as a list', () => { + renderEditor({ + interaction: block(CHOICE_SINGLE_SELECT_XML), + questionType: QuestionType.SINGLE_SELECT, + }); + const list = screen.getByRole('list'); + // Asserted as an attribute, not a role: jsdom gives a bare
    the implicit list role, + // so the explicit one Safari needs would be deletable with this test still green. + expect(list).toHaveAttribute('role', 'list'); + const items = within(list).getAllByRole('listitem'); + expect(items).toHaveLength(3); + items.forEach(item => expect(within(item).getAllByRole('radio')).toHaveLength(1)); + }); + + it('keeps the radiogroup around the list across a re-render', async () => { + renderEditor({ + interaction: block(CHOICE_SINGLE_SELECT_XML), + questionType: QuestionType.SINGLE_SELECT, + }); + const radiogroup = () => screen.getByRole('radiogroup', { name: tr.$tr('answersLabel') }); + expect(radiogroup()).toContainElement(screen.getByRole('list')); + + // Binding an `undefined` role on the wrapper makes Vue strip the role + // KRadioButtonGroup set on its own root, but only on a later patch. + await fireEvent.click(screen.getByRole('button', { name: tr.$tr('addChoiceBtn') })); + expect(radiogroup()).toContainElement(screen.getByRole('list')); + }); + + it('groups multi-select options under the answers header', () => { + renderEditor({ + interaction: block(CHOICE_MULTI_SELECT_XML), + questionType: QuestionType.MULTI_SELECT, + }); + const group = screen.getByRole('group', { name: tr.$tr('answersLabel') }); + expect(group).toContainElement(screen.getByRole('list')); + }); + it('icon buttons have accessible labels', () => { renderEditor({ interaction: block(CHOICE_SINGLE_SELECT_XML), 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 78ec470450..a01d149292 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionEditor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionEditor.vue @@ -71,8 +71,10 @@ :sortable="mode === 'edit'" @update:items="onReorderItems" > +
      }), ); +// `useDraggableUniverse` destructures this composable too, so the automock has to return +// a usable object rather than undefined. +jest.mock('kolibri-design-system/lib/composables/useKLiveRegion'); + +let sendPoliteMessage; + beforeEach(() => { mockSortableInstances = []; + sendPoliteMessage = jest.fn(); + useKLiveRegion.mockReturnValue({ sendPoliteMessage }); }); const itemLabel = number => tr.$tr('orderingItemLabel', { number }); @@ -134,6 +143,18 @@ describe('OrderingInteractionEditor', () => { expect(screen.queryByRole('button', { name: moveDownName(3) })).not.toBeInTheDocument(); }); + it('renders the items as a list', () => { + renderEditor({ + interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML), + questionType: QuestionType.ORDERING, + }); + const list = screen.getByRole('list'); + // Asserted as an attribute, not a role: jsdom gives a bare
        the implicit list role, + // so the explicit one Safari needs would be deletable with this test still green. + expect(list).toHaveAttribute('role', 'list'); + expect(within(list).getAllByRole('listitem')).toHaveLength(3); + }); + it('reorders the items when a row is dragged to a new position', async () => { const { emitted } = renderEditor({ interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML), @@ -160,6 +181,43 @@ describe('OrderingInteractionEditor', () => { ); }); + it('announces the moved item and its new position', async () => { + const user = userEvent.setup(); + renderEditor({ + interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML), + questionType: QuestionType.ORDERING, + }); + await user.click(screen.getByRole('button', { name: moveUpName(2) })); + + expect(sendPoliteMessage).toHaveBeenCalledWith( + dragTr.$tr('itemMovedToPosition', { item: itemLabel(2), position: 1, total: 3 }), + ); + }); + + it('keeps focus on the move-up button of the item that moved', async () => { + const user = userEvent.setup(); + renderEditor({ + interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML), + questionType: QuestionType.ORDERING, + }); + await user.click(screen.getByRole('button', { name: moveUpName(3) })); + + // The moved item is now second, so its widget is the one labelled for position 2. + expect(screen.getByRole('button', { name: moveUpName(2) })).toHaveFocus(); + }); + + it('moves focus to the move-down button when an item lands first', async () => { + const user = userEvent.setup(); + renderEditor({ + interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML), + questionType: QuestionType.ORDERING, + }); + await user.click(screen.getByRole('button', { name: moveUpName(2) })); + + // First position hides move-up, so focus has to fall back to move-down. + expect(screen.getByRole('button', { name: moveDownName(1) })).toHaveFocus(); + }); + it('disables delete button when only one item remains', async () => { const singleItemXml = ` Mercury