From ddad9851a6a8e820a7961c325d44c81e5f86a3c1 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Wed, 7 Oct 2026 14:42:30 -0500 Subject: [PATCH 01/17] Write no warning when not every response can be scored An item with several responses gets no response processing while any of them has nothing to score it by, such as an inline choice dropdown with no correct answer yet. That is an unfinished item, which validation already reports to the author, so it is not logged as well. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../serialization/__tests__/assembleItem.spec.js | 11 +---------- .../QTIEditor/serialization/assembleItem.js | 16 +++++----------- 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js index 22867eea52..73faefa612 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js @@ -239,11 +239,9 @@ describe('assembleItemXml', () => { it('writes no processing for a lone response declaration with nothing to score it by', () => { // A free response: there is no answer to score against, which is not a mistake. - const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); const xml = assembleWith([declaration('RESPONSE')]); expect(parseXML(xml).querySelector('qti-response-processing')).toBeNull(); expect(identifiersOf(xml, 'qti-outcome-declaration')).toEqual(['SCORE']); - expect(warn).not.toHaveBeenCalled(); }); it('averages the scoring rules of several scorable response declarations', () => { @@ -255,28 +253,21 @@ describe('assembleItemXml', () => { expect(xml).toContain(EXPECTED_PROCESSING); }); - it('writes no processing and warns when a response declaration cannot be scored', () => { - const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + it('writes no processing when a response declaration cannot be scored', () => { const xml = assembleWith([ declaration('response_xq7tbn2c', 'choice_a1b2c3d4'), declaration('response_pw4rzk8d'), ]); expect(parseXML(xml).querySelector('qti-response-processing')).toBeNull(); expect(identifiersOf(xml, 'qti-outcome-declaration')).toEqual(['SCORE']); - expect(warn).toHaveBeenCalledTimes(1); - expect(warn.mock.calls[0][0]).toContain('response_pw4rzk8d'); - expect(warn.mock.calls[0][0]).not.toContain('response_xq7tbn2c'); }); it('treats a response declaration it cannot read as unscorable', () => { - const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); const xml = assembleWith([ declaration('response_xq7tbn2c', 'choice_a1b2c3d4'), '', ]); expect(parseXML(xml).querySelector('qti-response-processing')).toBeNull(); - expect(warn).toHaveBeenCalledTimes(1); - expect(warn.mock.calls[0][0]).toContain('response_pw4rzk8d'); }); describe('element order', () => { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js index 80e1872d17..419ab2f854 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js @@ -119,23 +119,17 @@ function buildScoringNodes(declNodes) { return { outcomeDeclarations, responseProcessing: rule ? buildRulesNode(SCORE, [rule]) : null }; } - const scored = declNodes.map(node => ({ - identifier: node.getAttribute('identifier'), - rule: readDeclaration(node)?.getScoringRule(RAW_SCORE) ?? null, - })); - const unscorable = scored.filter(({ rule }) => !rule).map(({ identifier }) => identifier); - if (unscorable.length) { - // eslint-disable-next-line no-console - console.warn( - `[QTI Editor] Writing no response processing: cannot score ${unscorable.join(', ')}`, - ); + const rules = declNodes.map(node => readDeclaration(node)?.getScoringRule(RAW_SCORE) ?? null); + // Expected while an item is still being written, such as an inline choice dropdown with no + // correct answer yet; validation reports that to the author, so it is not logged here. + if (rules.includes(null)) { return { outcomeDeclarations, responseProcessing: null }; } return { outcomeDeclarations: [...outcomeDeclarations, buildOutcomeDeclarationNode(RAW_SCORE)], responseProcessing: buildRulesNode(RAW_SCORE, [ - ...scored.map(({ rule }) => rule), + ...rules, buildXmlNode({ tag: 'qti-set-outcome-value', attrs: { identifier: SCORE }, From d3cb3bf0663fc9664023b2780d28588f51c3db8b Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Wed, 7 Oct 2026 14:42:32 -0500 Subject: [PATCH 02/17] Fit the prominent insert action in with the toolbar's other tools An unavailable action now fades as the toolbar's disabled buttons do, with a not-allowed cursor, and no longer changes on hover or press as if it could be used. In a small window it shows only its icon, keeping its name for assistive technology, since its full label ran past the toolbar's edge in a narrow window or at a high zoom. As a last resort, a toolbar still too wide for its editor scrolls. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../__tests__/useInlineChoicePassage.spec.js | 9 +++++ .../TipTapEditor/components/EditorToolbar.vue | 39 ++++++++++++++++--- .../__tests__/EditorToolbar.spec.js | 31 +++++++++++++++ 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/useInlineChoicePassage.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/useInlineChoicePassage.spec.js index df1e61fbdc..73580a66f8 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/useInlineChoicePassage.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/useInlineChoicePassage.spec.js @@ -8,6 +8,15 @@ import { stubProseMirrorLayout } from 'shared/utils/testing'; // jsdom defines `ontouchstart`, which would put the editor in its touch layout. jest.mock('shared/utils/browserInfo', () => ({ isTouchDevice: false })); +// KDS reads the window as small until it has measured it, which would swap Insert for its icon +// button after the first render. +jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { + const { ref } = require('vue'); + return { + __esModule: true, + default: () => ({ windowIsSmall: ref(false) }), + }; +}); const dropdowns = editor => getDropdowns(editor.getHTML()); diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue index 64400ddbbb..ca87788802 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue @@ -100,8 +100,10 @@ aria-hidden="true" class="more-button-icon" > + - + @@ -165,6 +171,8 @@ + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/InlineChoiceNode.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/InlineChoiceNode.js index 47ee3df60a..b236da4a4e 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/InlineChoiceNode.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/InlineChoiceNode.js @@ -1,7 +1,7 @@ // The resolver does not read package `exports` subpaths. /* eslint-disable import/no-unresolved */ import { isHistoryTransaction } from '@tiptap/pm/history'; -import { Fragment, Slice } from '@tiptap/pm/model'; +import { DOMSerializer, Fragment, Slice } from '@tiptap/pm/model'; import { Plugin } from '@tiptap/pm/state'; /* eslint-enable import/no-unresolved */ import { @@ -14,11 +14,19 @@ import { import { VueNodeViewRenderer } from '@tiptap/vue-2'; import { generateRandomSlug } from '../../utils/generateRandomSlug'; import InlineChoiceChip from './components/InlineChoiceChip'; -import { CORRECT_ATTR, DROPDOWN, OPTION, readDropdown } from './parse'; +import { + COPIED_DROPDOWN, + CORRECT_ATTR, + DROPDOWN, + OPTION, + copiedDropdown, + readCopiedDropdown, + readDropdown, +} from './parse'; const NODE_NAME = 'inlineChoice'; -const isChip = node => node.type.name === NODE_NAME; +export const isChip = node => node.type.name === NODE_NAME; export const findChip = (doc, responseIdentifier) => findChildren( @@ -88,6 +96,17 @@ function deleteChip(view, event) { return true; } +/** + * A chip read from HTML holding no options, such as a copy whose dropdown cannot be read, gets + * one: QTI requires one in every dropdown, and every save is checked against it. It holds the + * element's text, which in a copy is the correct answer; the missing id is filled in on insert. + */ +function readStoredChip(el) { + const dropdown = readDropdown(el); + if (dropdown.options.length) return dropdown; + return { ...dropdown, options: [{ id: '', text: el.textContent.trim() }] }; +} + function reidentify(fragment, taken) { const children = []; fragment.forEach(child => { @@ -122,7 +141,11 @@ export const InlineChoiceNode = Node.create({ }, parseHTML() { - return [{ tag: DROPDOWN, getAttrs: readDropdown }]; + // A copied chip first, so it is not read as the stored form, which it also matches. + return [ + { tag: COPIED_DROPDOWN, getAttrs: readCopiedDropdown }, + { tag: DROPDOWN, getAttrs: readStoredChip }, + ]; }, renderHTML({ node }) { @@ -136,6 +159,10 @@ export const InlineChoiceNode = Node.create({ ]; }, + // No `renderText`: TipTap's typing shortcuts read text through it, so a chip read as its answer + // could start one (a `-` answer at the start of a paragraph and a space make a list), and be + // deleted as the characters typed for it. Plain text leaves a chip out; copied HTML has it. + addCommands() { return { // The selected text becomes the chip's correct option. With no text, or with an inline @@ -190,9 +217,17 @@ export const InlineChoiceNode = Node.create({ }, addProseMirrorPlugins() { + const serializer = DOMSerializer.fromSchema(this.editor.schema); + // Copies write a chip as `copiedDropdown`, which another passage reads back whole and other + // editors as its correct answer. + const clipboardSerializer = new DOMSerializer( + { ...serializer.nodes, [this.name]: node => copiedDropdown(node.attrs) }, + serializer.marks, + ); return [ new Plugin({ props: { + clipboardSerializer, handleKeyDown(view, event) { return ['Backspace', 'Delete'].includes(event.key) && deleteChip(view, event); }, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/Editor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/Editor.spec.js new file mode 100644 index 0000000000..168131323b --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/Editor.spec.js @@ -0,0 +1,850 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import { nextTick } from 'vue'; +import VueRouter from 'vue-router'; +import InlineChoiceEditor from '../Editor.vue'; +import { inlineChoiceInteractionDescriptor as descriptor } from '../Descriptor'; +import { getDropdowns } from '../parse'; +import { QuestionType } from '../../../constants'; +import { qtiEditorStrings as tr } from '../../../qtiEditorStrings'; +import { CHIP, chip } from './renderPassage'; +import { dragSortStrings as dragTr } from 'shared/views/dragSort/dragSortStrings'; +import { getTipTapEditorStrings } from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditorStrings'; +import { stubProseMirrorLayout } from 'shared/utils/testing'; + +// jsdom defines `ontouchstart`, which would put the editor in its touch layout. +jest.mock('shared/utils/browserInfo', () => ({ isTouchDevice: false })); +jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { + const { ref } = require('vue'); + return { + __esModule: true, + default: () => ({ windowIsSmall: ref(false) }), + }; +}); +// `useDraggableUniverse` destructures this composable, so the automock has to return a +// usable object rather than undefined. +jest.mock('kolibri-design-system/lib/composables/useKLiveRegion', () => ({ + __esModule: true, + default: () => ({ sendPoliteMessage: jest.fn() }), +})); + +const PASSAGE = `

The Earth ${chip('r1', [ + ['sun', 'Sun'], + ['moon', 'Moon'], + ['stars', 'Stars'], +])} orbits, and ${chip( + 'r2', + [ + ['revolves', 'revolves'], + ['circles', 'circles'], + ], + 'revolves', +)} too.

`; + +const interactionOf = state => + descriptor.buildXML( + { prompt: '

Pick the words

', passage: PASSAGE, shuffle: false, ...state }, + QuestionType.INLINE_CHOICE, + ); + +let teleportTarget; + +beforeEach(() => { + teleportTarget = document.createElement('div'); + teleportTarget.id = 'settings-target'; + document.body.appendChild(teleportTarget); +}); + +afterEach(() => { + teleportTarget.remove(); +}); + +async function renderEditor(props = {}) { + const user = userEvent.setup(); + const rendered = render(InlineChoiceEditor, { + props: { + interaction: interactionOf(), + questionType: QuestionType.INLINE_CHOICE, + mode: 'edit', + teleportTargetId: 'settings-target', + ...props, + }, + routes: new VueRouter(), + }); + if (props.mode === 'view') { + await waitFor(() => + expect(screen.getAllByRole('img', { name: /answer dropdown/i })).not.toHaveLength(0), + ); + } else { + // The card opens on its question; the chips take a click once the passage is open. + await user.click(await screen.findByRole('button', { name: tr.editPassageLabel$() })); + await waitFor(() => expect(screen.getAllByRole('button', CHIP)).not.toHaveLength(0)); + } + return { ...rendered, user }; +} + +const chips = () => screen.getAllByRole('button', CHIP); +const dialog = () => screen.getByRole('dialog'); +const optionInputs = () => within(dialog()).getAllByRole('textbox', { name: /^Option \d+$/ }); +const optionLabel = number => tr.inlineChoiceOptionLabel$({ number }); +/** The part of the editor under the heading with that name: its field, help and errors. */ +const section = name => screen.getByRole('heading', { name }).parentElement; + +/** The dropdowns of the last interaction the editor emitted, as saved and read back. */ +function savedDropdowns(emitted) { + const [interaction] = emitted['update:interaction'].at(-1); + const state = descriptor.parse(interaction.bodyXml, interaction.responseDeclarations); + return getDropdowns(state.passage); +} + +async function openChip(user, index) { + await user.click(chips()[index]); + return screen.findByRole('dialog'); +} + +describe('InlineChoiceEditor', () => { + stubProseMirrorLayout(); + + // jsdom lays nothing out, so every element would read as hidden to the focus helpers. + let offsetParent; + beforeAll(() => { + offsetParent = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetParent'); + Object.defineProperty(HTMLElement.prototype, 'offsetParent', { + configurable: true, + get() { + return this.parentNode; + }, + }); + }); + afterAll(() => { + Object.defineProperty(HTMLElement.prototype, 'offsetParent', offsetParent); + }); + + it('shows the optional question and the passage editor with its helper text', async () => { + await renderEditor(); + expect( + within(section(tr.questionOptionalLabel$())).getByText('Pick the words'), + ).toBeInTheDocument(); + const passage = within(section(tr.passageEditorLabel$())); + expect(passage.getByText(tr.passageEditorDescription$())).toBeInTheDocument(); + expect(passage.getByRole('button', { name: tr.insertInlineChoice$() })).toBeInTheDocument(); + expect(passage.getAllByRole('button', CHIP)).toHaveLength(2); + }); + + it('opens on the question, focused, with the passage closed', async () => { + render(InlineChoiceEditor, { + props: { + interaction: interactionOf(), + questionType: QuestionType.INLINE_CHOICE, + mode: 'edit', + teleportTargetId: 'settings-target', + }, + routes: new VueRouter(), + }); + const editPassage = await screen.findByRole('button', { name: tr.editPassageLabel$() }); + await waitFor(() => + expect(screen.getByText('Pick the words').closest('.ProseMirror')).toHaveFocus(), + ); + expect( + screen.queryByRole('button', { name: tr.insertInlineChoice$() }), + ).not.toBeInTheDocument(); + expect(screen.queryByRole('button', CHIP)).not.toBeInTheDocument(); + expect(editPassage).toBeInTheDocument(); + }); + + it('shows no options panel until a chip is opened', async () => { + await renderEditor(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + describe('opening a chip', () => { + it('shows its options in a modal dialog named after it', async () => { + const { user } = await renderEditor(); + const panel = await openChip(user, 0); + + expect(panel).toHaveAttribute('aria-modal', 'true'); + expect(panel).toHaveAccessibleName(tr.inlineChoiceOptionsDialogLabel$({ number: 1 })); + expect(optionInputs().map(input => input.value)).toEqual(['Sun', 'Moon', 'Stars']); + expect(chips()[0]).toHaveAttribute('aria-expanded', 'true'); + }); + + it('moves focus into its options list', async () => { + const { user } = await renderEditor(); + await openChip(user, 0); + await waitFor(() => expect(optionInputs()[0]).toHaveFocus()); + }); + + it('opens with Enter on the chip', async () => { + const { user } = await renderEditor(); + // The passage opens focused; wait for that before moving to the chip. + await waitFor(() => expect(document.activeElement).toHaveClass('ProseMirror')); + chips()[1].focus(); + await user.keyboard('{Enter}'); + await screen.findByRole('dialog'); + expect(optionInputs().map(input => input.value)).toEqual(['revolves', 'circles']); + }); + + it('shows the other chip’s options when another chip is opened', async () => { + const { user } = await renderEditor(); + await openChip(user, 0); + await openChip(user, 1); + + expect(screen.getAllByRole('dialog')).toHaveLength(1); + expect(dialog()).toHaveAccessibleName(tr.inlineChoiceOptionsDialogLabel$({ number: 2 })); + expect(optionInputs().map(input => input.value)).toEqual(['revolves', 'circles']); + }); + }); + + describe('editing options', () => { + it('edits an option’s text, which the chip label and the saved item follow', async () => { + const { emitted, user } = await renderEditor(); + await openChip(user, 1); + const [input] = optionInputs(); + await user.clear(input); + await user.type(input, 'spins'); + + expect(input).toHaveValue('spins'); + expect(chips()[1]).toHaveTextContent('spins'); + expect(savedDropdowns(emitted())[1].options).toEqual([ + { id: 'revolves', text: 'spins' }, + { id: 'circles', text: 'circles' }, + ]); + }); + + it('adds an option, updating the chip count, and focuses it', async () => { + const { emitted, user } = await renderEditor(); + await openChip(user, 1); + await user.click(screen.getByRole('button', { name: tr.addInlineChoiceOptionBtn$() })); + + expect(optionInputs()).toHaveLength(3); + await waitFor(() => expect(optionInputs()[2]).toHaveFocus()); + await user.keyboard('rotates'); + expect(chips()[1]).toHaveTextContent(/^3/); + expect(savedDropdowns(emitted())[1].options.map(o => o.text)).toEqual([ + 'revolves', + 'circles', + 'rotates', + ]); + }); + + it('removes an option', async () => { + const { emitted, user } = await renderEditor(); + await openChip(user, 0); + await user.click( + screen.getByRole('button', { name: tr.deleteInlineChoiceOptionBtn$({ number: 2 }) }), + ); + + expect(optionInputs().map(input => input.value)).toEqual(['Sun', 'Stars']); + expect(chips()[0]).toHaveTextContent(/^2/); + expect(savedDropdowns(emitted())[0].options.map(o => o.text)).toEqual(['Sun', 'Stars']); + }); + + it('keeps focus in the panel on the option that takes a removed one’s place', async () => { + const { user } = await renderEditor(); + await openChip(user, 0); + const remove = number => + screen.getByRole('button', { name: tr.deleteInlineChoiceOptionBtn$({ number }) }); + + remove(2).focus(); + await user.keyboard('{Enter}'); + await waitFor(() => expect(optionInputs()[1]).toHaveFocus()); + expect(optionInputs()[1]).toHaveValue('Stars'); + + remove(2).focus(); + await user.keyboard('{Enter}'); + await waitFor(() => expect(optionInputs()[0]).toHaveFocus()); + expect(optionInputs()[0]).toHaveValue('Sun'); + }); + + it('disables removing a chip’s only option', async () => { + const { user } = await renderEditor({ + interaction: interactionOf({ + passage: `

The ${chip('r1', [['sun', 'Sun']], 'sun')}

`, + }), + }); + await openChip(user, 0); + const remove = screen.getByRole('button', { + name: tr.deleteInlineChoiceOptionBtn$({ number: 1 }), + }); + expect(remove).toBeDisabled(); + }); + + it('marks exactly one option correct, and the chip shows it', async () => { + const { emitted, user } = await renderEditor(); + await openChip(user, 0); + const radio = number => + screen.getByRole('radio', { name: tr.markInlineChoiceOptionCorrect$({ number }) }); + + await user.click(radio(2)); + expect(radio(2)).toBeChecked(); + expect(chips()[0]).toHaveTextContent('Moon'); + + await user.click(radio(3)); + expect(radio(2)).not.toBeChecked(); + expect(radio(3)).toBeChecked(); + expect(chips()[0]).toHaveTextContent('Stars'); + expect(savedDropdowns(emitted())[0].correctId).toBe('stars'); + }); + + it('keeps the correct mark on the same option when it moves', async () => { + const { emitted, user } = await renderEditor(); + await openChip(user, 1); + await user.click(screen.getByRole('button', { name: tr.addInlineChoiceOptionBtn$() })); + await user.keyboard('rotates'); + await user.click( + screen.getByRole('button', { + name: dragTr.$tr('moveItemDownLabel', { item: optionLabel(1) }), + }), + ); + + expect(optionInputs().map(input => input.value)).toEqual(['circles', 'revolves', 'rotates']); + expect( + screen.getByRole('radio', { name: tr.markInlineChoiceOptionCorrect$({ number: 2 }) }), + ).toBeChecked(); + const [, dropdown] = savedDropdowns(emitted()); + expect(dropdown.options.map(o => o.text)).toEqual(['circles', 'revolves', 'rotates']); + expect(dropdown.correctId).toBe('revolves'); + }); + + it('leaves the chip with no correct answer when the correct option is removed', async () => { + const { emitted, user } = await renderEditor(); + await openChip(user, 1); + await user.click(screen.getByRole('button', { name: tr.addInlineChoiceOptionBtn$() })); + await user.click( + screen.getByRole('button', { name: tr.deleteInlineChoiceOptionBtn$({ number: 1 }) }), + ); + + expect(chips()[1]).toHaveTextContent(tr.addAnswers$()); + expect(savedDropdowns(emitted())[1].correctId).toBeNull(); + }); + + it('restores the list when the edit is undone in the passage', async () => { + const { user } = await renderEditor(); + await openChip(user, 0); + await user.click( + screen.getByRole('button', { name: tr.deleteInlineChoiceOptionBtn$({ number: 1 }) }), + ); + expect(optionInputs().map(input => input.value)).toEqual(['Moon', 'Stars']); + + // A press on the passage's toolbar closes the panel. + const { undo$, redo$ } = getTipTapEditorStrings(); + await user.click(screen.getByRole('button', { name: undo$() })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + await openChip(user, 0); + expect(optionInputs().map(input => input.value)).toEqual(['Sun', 'Moon', 'Stars']); + + await user.click(screen.getByRole('button', { name: redo$() })); + await openChip(user, 0); + expect(optionInputs().map(input => input.value)).toEqual(['Moon', 'Stars']); + }); + + it('undoes and redoes an option’s text with the passage’s history alone', async () => { + const { user } = await renderEditor(); + await openChip(user, 1); + const [input] = optionInputs(); + await user.type(input, 's'); + expect(input).toHaveValue('revolvess'); + + // What the browser's Edit menu sends; true when the field's own history was kept out. + async function historyInput(inputType) { + const event = new InputEvent('beforeinput', { inputType, cancelable: true }); + await fireEvent(input, event); + return event.defaultPrevented; + } + expect(await historyInput('historyUndo')).toBe(true); + expect(input).toHaveValue('revolves'); + expect(chips()[1]).toHaveTextContent('revolves'); + expect(input).toHaveFocus(); + + // The undo was not recorded as an edit, for a second one to type it back. + await historyInput('historyUndo'); + expect(input).toHaveValue('revolves'); + + expect(await historyInput('historyRedo')).toBe(true); + expect(input).toHaveValue('revolvess'); + }); + + // The field's own redo never has anything to redo, as its undo never runs, so the browser + // sends no `historyRedo` for these. + it.each([ + ['Ctrl+Shift+Z', '{Control>}{Shift>}z{/Shift}{/Control}'], + ['Ctrl+Y', '{Control>}y{/Control}'], + ])('redoes an option’s text with %s', async (_, redoKeys) => { + const { user } = await renderEditor(); + await openChip(user, 1); + const [input] = optionInputs(); + await user.type(input, 's'); + + await user.keyboard('{Control>}z{/Control}'); + expect(input).toHaveValue('revolves'); + await user.keyboard(redoKeys); + expect(input).toHaveValue('revolvess'); + expect(chips()[1]).toHaveTextContent('revolvess'); + }); + + it('keeps focus in the panel when an undo takes away the focused option', async () => { + const { user } = await renderEditor(); + await openChip(user, 1); + await user.click(screen.getByRole('button', { name: tr.addInlineChoiceOptionBtn$() })); + await waitFor(() => expect(optionInputs()[2]).toHaveFocus()); + + await user.keyboard('{Control>}z{/Control}'); + expect(optionInputs().map(input => input.value)).toEqual(['revolves', 'circles']); + await waitFor(() => expect(optionInputs()[1]).toHaveFocus()); + + // Still in the panel, Escape still closes it. + await user.keyboard('{Escape}'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('keeps focus in the panel when a redo takes away the focused option', async () => { + const { user } = await renderEditor(); + await openChip(user, 0); + await user.click( + screen.getByRole('button', { name: tr.deleteInlineChoiceOptionBtn$({ number: 2 }) }), + ); + await user.keyboard('{Control>}z{/Control}'); + expect(optionInputs().map(input => input.value)).toEqual(['Sun', 'Moon', 'Stars']); + optionInputs()[1].focus(); + + await user.keyboard('{Control>}{Shift>}z{/Shift}{/Control}'); + expect(optionInputs().map(input => input.value)).toEqual(['Sun', 'Stars']); + await waitFor(() => expect(optionInputs()[1]).toHaveFocus()); + }); + + it('switches to the chip an undo or redo changed, for the step to be seen', async () => { + const { user } = await renderEditor(); + await openChip(user, 1); + await user.type(optionInputs()[0], 's'); + await user.keyboard('{Escape}'); + await openChip(user, 0); + await waitFor(() => expect(optionInputs()[0]).toHaveFocus()); + const dialogOf = number => + screen.getByRole('dialog', { name: tr.inlineChoiceOptionsDialogLabel$({ number }) }); + + await user.keyboard('{Control>}z{/Control}'); + await waitFor(() => expect(dialogOf(2)).toContainElement(document.activeElement)); + expect(optionInputs().map(input => input.value)).toEqual(['revolves', 'circles']); + + await user.keyboard('{Control>}{Shift>}z{/Shift}{/Control}'); + expect(optionInputs().map(input => input.value)).toEqual(['revolvess', 'circles']); + expect(dialogOf(2)).toContainElement(document.activeElement); + }); + + it('undoes and redoes from the panel’s other controls', async () => { + const { user } = await renderEditor(); + await openChip(user, 0); + const radio = screen.getByRole('radio', { + name: tr.markInlineChoiceOptionCorrect$({ number: 2 }), + }); + await user.click(radio); + await user.keyboard('{Control>}z{/Control}'); + expect(radio).not.toBeChecked(); + await user.keyboard('{Control>}{Shift>}z{/Shift}{/Control}'); + expect(radio).toBeChecked(); + + await user.click( + screen.getByRole('button', { + name: dragTr.$tr('moveItemDownLabel', { item: optionLabel(1) }), + }), + ); + expect(optionInputs().map(input => input.value)).toEqual(['Moon', 'Sun', 'Stars']); + await user.keyboard('{Control>}z{/Control}'); + expect(optionInputs().map(input => input.value)).toEqual(['Sun', 'Moon', 'Stars']); + }); + + it('keeps focus in the panel when a redo takes away the focused remove button', async () => { + const { user } = await renderEditor(); + await openChip(user, 0); + const remove = () => + screen.getByRole('button', { name: tr.deleteInlineChoiceOptionBtn$({ number: 2 }) }); + await user.click(remove()); + await user.keyboard('{Control>}z{/Control}'); + expect(optionInputs().map(input => input.value)).toEqual(['Sun', 'Moon', 'Stars']); + remove().focus(); + + await user.keyboard('{Control>}{Shift>}z{/Shift}{/Control}'); + expect(optionInputs().map(input => input.value)).toEqual(['Sun', 'Stars']); + await waitFor(() => expect(optionInputs()[1]).toHaveFocus()); + }); + }); + + describe('Shuffle', () => { + const shuffle = () => screen.getByRole('checkbox', { name: tr.shuffleAnswersLabel$() }); + const moveDown = () => + screen.queryByRole('button', { + name: dragTr.$tr('moveItemDownLabel', { item: optionLabel(1) }), + }); + + it('is off for a new question', async () => { + render(InlineChoiceEditor, { + props: { + interaction: descriptor.buildXML(descriptor.parse('', []), QuestionType.INLINE_CHOICE), + questionType: QuestionType.INLINE_CHOICE, + mode: 'edit', + teleportTargetId: 'settings-target', + }, + routes: new VueRouter(), + }); + expect( + await screen.findByRole('checkbox', { name: tr.shuffleAnswersLabel$() }), + ).not.toBeChecked(); + }); + + it('hides the reorder controls while on, and shows them again when off', async () => { + const { user } = await renderEditor(); + await openChip(user, 0); + expect(moveDown()).toBeInTheDocument(); + + await user.click(shuffle()); + expect(moveDown()).not.toBeInTheDocument(); + + await user.click(shuffle()); + expect(moveDown()).toBeInTheDocument(); + }); + + it('is saved on every dropdown, and read back', async () => { + const { emitted, user } = await renderEditor(); + await user.click(shuffle()); + + const [interaction] = emitted()['update:interaction'].at(-1); + expect([...interaction.bodyXml.matchAll(/shuffle="(\w+)"/g)].map(m => m[1])).toEqual([ + 'true', + 'true', + ]); + expect(descriptor.parse(interaction.bodyXml, interaction.responseDeclarations).shuffle).toBe( + true, + ); + }); + }); + + describe('focus', () => { + const closeButton = () => screen.getByRole('button', { name: tr.closeInlineChoiceOptions$() }); + const addOptionButton = () => + screen.getByRole('button', { name: tr.addInlineChoiceOptionBtn$() }); + + it('wraps from its last control to its first with Tab', async () => { + const { user } = await renderEditor(); + await openChip(user, 0); + addOptionButton().focus(); + + await user.tab(); + expect(closeButton()).toHaveFocus(); + }); + + // The panel opens with focus put on its first option, not tabbed in. + it('wraps from its first control to its last with Shift+Tab, from the start', async () => { + const { user } = await renderEditor(); + await openChip(user, 0); + await waitFor(() => expect(optionInputs()[0]).toHaveFocus()); + closeButton().focus(); + + await user.tab({ shift: true }); + expect(addOptionButton()).toHaveFocus(); + + closeButton().focus(); + await user.tab({ shift: true }); + expect(addOptionButton()).toHaveFocus(); + }); + + it('returns to the chip when Escape closes the panel', async () => { + const { user } = await renderEditor(); + await openChip(user, 0); + await waitFor(() => expect(optionInputs()[0]).toHaveFocus()); + + await user.keyboard('{Escape}'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + await waitFor(() => expect(chips()[0]).toHaveFocus()); + expect(chips()[0]).toHaveAttribute('aria-expanded', 'false'); + }); + + it('closes with Escape after a click on a part of the panel that takes no focus', async () => { + const { user } = await renderEditor(); + await openChip(user, 0); + await user.click(within(dialog()).getByText(tr.inlineChoiceOptionsLabel$())); + expect(dialog()).toContainElement(document.activeElement); + + await user.keyboard('{Escape}'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + await waitFor(() => expect(chips()[0]).toHaveFocus()); + }); + + it('returns to the chip when the close control closes the panel', async () => { + const { user } = await renderEditor(); + await openChip(user, 1); + await user.click(screen.getByRole('button', { name: tr.closeInlineChoiceOptions$() })); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + await waitFor(() => expect(chips()[1]).toHaveFocus()); + }); + }); + + it('closes the panel when its chip leaves the passage', async () => { + const { user } = await renderEditor(); + // Inserting a chip opens it; undoing the insert takes it away. + await user.click(screen.getByRole('button', { name: tr.insertInlineChoice$() })); + await waitFor(() => expect(chips()).toHaveLength(3)); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + // From the keyboard, as a click in the passage would close the panel by itself. + const editor = section(tr.passageEditorLabel$()).querySelector('.ProseMirror'); + editor.focus(); + await fireEvent.keyDown(editor, { key: 'z', keyCode: 90, ctrlKey: true }); + expect(chips()).toHaveLength(2); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('closes the panel on a click in the passage, for the passage to be edited', async () => { + const { user } = await renderEditor(); + await openChip(user, 0); + const text = section(tr.passageEditorLabel$()).querySelector('.ProseMirror p'); + // ProseMirror finds where a click lands from its coordinates, which jsdom lacks. + document.elementFromPoint = () => text; + try { + await user.click(text); + } finally { + delete document.elementFromPoint; + } + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(chips()[0]).toHaveAttribute('aria-expanded', 'false'); + }); + + describe('on a click outside the editor', () => { + let outside; + beforeEach(() => { + outside = document.createElement('button'); + outside.textContent = 'Outside'; + document.body.append(outside); + }); + afterEach(() => outside.remove()); + + it('closes the panel, leaving focus where the click put it', async () => { + const { user } = await renderEditor(); + await openChip(user, 0); + await waitFor(() => expect(optionInputs()[0]).toHaveFocus()); + + await user.click(outside); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(chips()[0]).toHaveAttribute('aria-expanded', 'false'); + await nextTick(); + await new Promise(resolve => requestAnimationFrame(resolve)); + expect(outside).toHaveFocus(); + }); + + it('stays open after a press that began in it, as when an option is dragged out', async () => { + const { user } = await renderEditor(); + await openChip(user, 0); + + await fireEvent.pointerDown(optionInputs()[0]); + await fireEvent.pointerUp(outside); + // The browser clicks the closest element that holds both ends of the press. + await fireEvent.click(document.body); + expect(dialog()).toBeInTheDocument(); + }); + + // As with the toolbar's menus, the click dismisses the popup; a second one closes the editor. + it('keeps the passage open', async () => { + const { user } = await renderEditor(); + await openChip(user, 0); + + await user.click(outside); + expect(section(tr.passageEditorLabel$()).querySelector('.ProseMirror')).toHaveAttribute( + 'contenteditable', + 'true', + ); + }); + }); + + it('keeps the panel open until a click on another chip switches it to that chip', async () => { + const { user } = await renderEditor(); + await openChip(user, 0); + + await fireEvent.pointerDown(chips()[1]); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + await user.click(chips()[1]); + await waitFor(() => + expect(optionInputs().map(input => input.value)).toEqual(['revolves', 'circles']), + ); + expect(chips()[0]).toHaveAttribute('aria-expanded', 'false'); + expect(chips()[1]).toHaveAttribute('aria-expanded', 'true'); + }); + + it('opens a chip it inserts, ready for its options', async () => { + const { emitted, user } = await renderEditor(); + await user.click(screen.getByRole('button', { name: tr.insertInlineChoice$() })); + + await screen.findByRole('dialog'); + await waitFor(() => expect(optionInputs()[0]).toHaveFocus()); + await user.keyboard('Venus'); + const saved = savedDropdowns(emitted()); + expect(saved).toHaveLength(3); + expect(saved.map(d => d.options.map(o => o.text))).toContainEqual(['Venus']); + }); + + it('focuses the first option of a chip inserted from the keyboard', async () => { + const { user } = await renderEditor(); + await waitFor(() => expect(document.activeElement).toHaveClass('ProseMirror')); + // Off the passage, which then takes focus back a frame after the insert. + screen.getByRole('button', { name: tr.insertInlineChoice$() }).focus(); + await user.keyboard('{Enter}'); + + await screen.findByRole('dialog'); + await waitFor(() => expect(optionInputs()[0]).toHaveFocus()); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + expect(optionInputs()[0]).toHaveFocus(); + }); + + it('saves the passage images in their stored form while a chip is open', async () => { + const image = '83ab37e959e03fec7be3e1bf834cb169.jpg'; + const { emitted, user } = await renderEditor({ + interaction: interactionOf({ passage: `${PASSAGE}

a

` }), + }); + await openChip(user, 1); + await user.type(optionInputs()[0], 's'); + + const [interaction] = emitted()['update:interaction'].at(-1); + expect(interaction.bodyXml).toContain(`src="${image}"`); + expect(interaction.bodyXml).not.toContain('/content/storage/'); + }); + + it('does not save which chip is open', async () => { + const { emitted, user } = await renderEditor(); + const before = emitted()['update:interaction'].at(-1)[0].bodyXml; + await openChip(user, 0); + expect(emitted()['update:interaction'].at(-1)[0].bodyXml).toBe(before); + }); + + it('restores every chip’s options and correct answer when reopened', async () => { + const { emitted, unmount, user } = await renderEditor(); + await openChip(user, 0); + await user.click( + screen.getByRole('radio', { name: tr.markInlineChoiceOptionCorrect$({ number: 3 }) }), + ); + const [saved] = emitted()['update:interaction'].at(-1); + unmount(); + + await renderEditor({ interaction: saved }); + expect(chips()[0]).toHaveTextContent('Stars'); + expect(chips()[1]).toHaveTextContent('revolves'); + await openChip(user, 0); + expect(optionInputs().map(input => input.value)).toEqual(['Sun', 'Moon', 'Stars']); + expect( + screen.getByRole('radio', { name: tr.markInlineChoiceOptionCorrect$({ number: 3 }) }), + ).toBeChecked(); + }); + + describe('validation', () => { + it('asks for a second option on a dropdown with one in its panel', async () => { + const { emitted, user } = await renderEditor({ + interaction: interactionOf({ + passage: `

The ${chip('r1', [['sun', 'Sun']], 'sun')}

`, + }), + }); + expect(emitted()['update:errors'].at(-1)[0]).toEqual([{ code: 'TOO_FEW_CHOICES', id: 'r1' }]); + + await openChip(user, 0); + expect(within(dialog()).getByText(tr.errorTooFewInlineChoiceOptions$())).toBeInTheDocument(); + }); + + it('marks the question incomplete and flags a chip without a correct answer', async () => { + const { emitted, user } = await renderEditor(); + expect(emitted()['update:errors'].at(-1)[0]).toEqual([ + { code: 'NO_CORRECT_ANSWER', id: 'r1' }, + ]); + expect( + within(section(tr.passageEditorLabel$())).getByText(tr.errorInlineChoiceOptionProblems$()), + ).toBeInTheDocument(); + expect(chips()[0]).toHaveAccessibleName(/needs attention/); + expect(chips()[1]).not.toHaveAccessibleName(/needs attention/); + + await openChip(user, 0); + expect(within(dialog()).getByText(tr.errorNoCorrectAnswer$())).toBeInTheDocument(); + }); + + it('shows a duplicate option on its row and on its chip', async () => { + const { user } = await renderEditor(); + await openChip(user, 1); + await user.click(screen.getByRole('button', { name: tr.addInlineChoiceOptionBtn$() })); + await user.keyboard('revolves'); + + expect(within(dialog()).getAllByText(tr.errorDuplicateChoiceContent$())).toHaveLength(2); + expect(optionInputs()[0]).toHaveAccessibleDescription(tr.errorDuplicateChoiceContent$()); + expect(optionInputs()[2]).toHaveAccessibleDescription(tr.errorDuplicateChoiceContent$()); + expect(chips()[1]).toHaveAccessibleName(/needs attention/); + }); + + it('shows a blank option in its row', async () => { + const { user } = await renderEditor(); + await openChip(user, 1); + await user.click(screen.getByRole('button', { name: tr.addInlineChoiceOptionBtn$() })); + + expect(optionInputs()[2]).toHaveAttribute('aria-invalid', 'true'); + expect(within(dialog()).getByText(tr.errorEmptyChoiceContent$())).toBeInTheDocument(); + expect(optionInputs()[2]).toHaveAccessibleDescription(tr.errorEmptyChoiceContent$()); + expect(optionInputs()[0]).not.toHaveAttribute('aria-describedby'); + }); + + it('shows a passage without dropdowns on the passage editor', async () => { + render(InlineChoiceEditor, { + props: { + interaction: interactionOf({ passage: '

Nothing to choose

' }), + questionType: QuestionType.INLINE_CHOICE, + mode: 'edit', + teleportTargetId: 'settings-target', + }, + routes: new VueRouter(), + }); + await screen.findByRole('heading', { name: tr.passageEditorLabel$() }); + const passageSection = within(section(tr.passageEditorLabel$())); + expect(passageSection.getByText(tr.errorNoInlineChoice$())).toBeInTheDocument(); + expect( + passageSection.getByRole('button', { name: tr.editPassageLabel$() }), + ).toBeInTheDocument(); + }); + }); + + describe('view mode', () => { + it('shows the question and the passage with the correct answers', async () => { + await renderEditor({ mode: 'view', showAnswers: true }); + expect(within(section(tr.questionLabel$())).getByText('Pick the words')).toBeInTheDocument(); + const [, revealed] = within(section(tr.passageLabel$())).getAllByRole('img', CHIP); + expect(revealed).toHaveTextContent('revolves'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect( + screen.queryByRole('heading', { name: tr.passageEditorLabel$() }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('heading', { name: tr.questionOptionalLabel$() }), + ).not.toBeInTheDocument(); + }); + + it('shows the answers while editing, even in the closed passage', async () => { + render(InlineChoiceEditor, { + props: { + interaction: interactionOf(), + questionType: QuestionType.INLINE_CHOICE, + mode: 'edit', + showAnswers: false, + teleportTargetId: 'settings-target', + }, + routes: new VueRouter(), + }); + const [, closed] = await screen.findAllByRole('img', { name: /answer dropdown/ }); + expect(closed).toHaveTextContent('revolves'); + expect(closed).not.toHaveTextContent(tr.chooseAnswer$()); + }); + + it('shows a choice to make in place of the answers without Show answers', async () => { + await renderEditor({ mode: 'view', showAnswers: false }); + const [, hidden] = screen.getAllByRole('img', { name: /answer dropdown/ }); + expect(hidden).not.toHaveTextContent('revolves'); + expect(hidden).toHaveTextContent(`2 ${tr.chooseAnswer$()}`); + expect(hidden).toHaveAccessibleName( + tr.answerDropdownHidden$({ label: tr.chooseAnswer$(), count: 2 }), + ); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/InlineChoiceNode.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/InlineChoiceNode.spec.js index 5d756e604a..0a5892da1b 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/InlineChoiceNode.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/InlineChoiceNode.spec.js @@ -1,5 +1,6 @@ -import { screen, waitFor } from '@testing-library/vue'; +import { render, screen, waitFor } from '@testing-library/vue'; import userEvent from '@testing-library/user-event'; +import VueRouter from 'vue-router'; // The resolver does not read package `exports` subpaths. // eslint-disable-next-line import/no-unresolved import { closeHistory } from '@tiptap/pm/history'; @@ -7,6 +8,7 @@ import { getDropdowns, parseInlineChoiceInteraction } from '../parse'; import { chip, findChip, renderPassage } from './renderPassage'; import { stubProseMirrorLayout } from 'shared/utils/testing'; import { getTipTapEditorStrings } from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditorStrings'; +import TipTapEditor from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue'; const { copy$, paste$ } = getTipTapEditorStrings(); @@ -16,6 +18,18 @@ jest.mock('shared/utils/browserInfo', () => ({ isTouchDevice: false })); // jsdom has no ClipboardEvent, which `pasteHTML` creates by default. const paste = (editor, html) => editor.view.pasteHTML(html, new Event('paste')); +/** Mounts a rich text editor without dropdowns, as the question field is; resolves its editor. */ +async function renderEditorWithoutChips() { + const onReady = jest.fn(); + render(TipTapEditor, { + props: { value: '

', mode: 'edit', format: 'html' }, + listeners: { ready: onReady }, + routes: new VueRouter(), + }); + await waitFor(() => expect(onReady).toHaveBeenCalled()); + return onReady.mock.calls[0][0]; +} + describe('InlineChoiceNode', () => { stubProseMirrorLayout(); @@ -193,6 +207,34 @@ describe('InlineChoiceNode', () => { }); }); + // TipTap's typing shortcuts read the text before the caret, so a chip read as its answer + // could start one, and be deleted as the characters typed for it. + it.each(['-', '#', '>'])( + 'starts no typing shortcut with a chip answering %s at the start of a paragraph', + async answer => { + const { editor } = await renderPassage({ + value: `

${chip( + 'r1', + [ + ['a', answer], + ['b', 'z'], + ], + 'a', + )}rest

`, + }); + editor.commands.focus(); + const { view } = editor; + const typeSpace = () => view.state.tr.insertText(' ', 2, 2); + const handled = view.someProp('handleTextInput', handle => + handle(view, 2, 2, ' ', typeSpace), + ); + if (!handled) view.dispatch(typeSpace()); + + expect(getDropdowns(editor.getHTML())).toHaveLength(1); + expect(editor.state.doc.firstChild.type.name).toBe('paragraph'); + }, + ); + describe('paste', () => { const source = chip( 'r1', @@ -253,6 +295,25 @@ describe('InlineChoiceNode', () => { expect(correctId).toBe(options[0].id); }); + // QTI requires an option in every dropdown, and every save is checked against it. + it.each([ + [ + 'a dropdown with no options', + '', + '', + ], + [ + 'a copy whose dropdown cannot be read', + 'sun', + 'sun', + ], + ])('gives %s one option, holding its text', async (_, html, text) => { + const { editor } = await renderPassage({ value: '

a

' }); + paste(editor, html); + const [pasted] = getDropdowns(editor.getHTML()); + expect(pasted.options).toEqual([{ id: expect.stringMatching(/./), text }]); + }); + it('gives a pasted chip missing its ids fresh ones', async () => { const { editor } = await renderPassage({ value: '

a

' }); paste( @@ -300,6 +361,48 @@ describe('InlineChoiceNode', () => { ]); }); + describe('a copy', () => { + // What copying the passage's first paragraph puts on the clipboard. TipTap writes the + // plain text from the selection. + const copyParagraph = editor => { + const to = editor.state.doc.firstChild.nodeSize - 1; + editor.commands.setTextSelection({ from: 1, to }); + return editor.view.serializeForClipboard(editor.state.selection.content()); + }; + + it('pastes into an editor without dropdowns as just its correct answer', async () => { + const { editor } = await renderPassage({ value: `

a ${source} b

` }); + const plain = await renderEditorWithoutChips(); + paste(plain, copyParagraph(editor).dom.innerHTML); + expect(plain.getHTML()).toBe('

a y b

'); + }); + + it('leaves a dropdown with no correct answer out of an editor without dropdowns', async () => { + const { editor } = await renderPassage({ + value: `

a ${chip('r1', [['c1', 'x']])} b

`, + }); + const plain = await renderEditorWithoutChips(); + paste(plain, copyParagraph(editor).dom.innerHTML); + expect(plain.getText()).not.toContain('x'); + }); + + // The plain text has no form a passage could read the chip back from. + it('leaves the chip out of its plain text', async () => { + const { editor } = await renderPassage({ value: `

a ${source} b

` }); + expect(copyParagraph(editor).text).toBe('a b'); + }); + + it('pastes into a passage as the whole dropdown', async () => { + const { editor } = await renderPassage({ value: `

a ${source} b

` }); + const { dom } = copyParagraph(editor); + editor.commands.setTextSelection(editor.state.doc.content.size - 1); + paste(editor, dom.innerHTML); + const [, copy] = getDropdowns(editor.getHTML()); + expect(copy.options.map(o => o.text)).toEqual(['x', 'y']); + expectFreshCopy(copy); + }); + }); + describe('with the toolbar Copy and Paste buttons', () => { let clipboard; @@ -373,6 +476,29 @@ describe('InlineChoiceNode', () => { expect(copy.options.map(o => o.text)).toEqual(['x', 'y']); expectFreshCopy(copy); }); + + it('copies a selected chip as its correct answer for an editor without dropdowns', async () => { + const { editor } = await renderPassage({ value: `

a${source}b

` }); + editor.commands.focus(); + editor.commands.setNodeSelection(findChip(editor, 'r1').pos); + await userEvent.click(screen.getByRole('button', { name: copy$() })); + await waitFor(() => expect(clipboard).toHaveLength(1)); + + const html = await (await clipboard[0].getType('text/html')).text(); + const plain = await renderEditorWithoutChips(); + paste(plain, html); + expect(plain.getText()).toBe('y'); + }); + + it('leaves a chip out of its plain text, as Ctrl+C does', async () => { + const { editor } = await renderPassage({ value: `

a ${source} b

` }); + editor.commands.focus(); + editor.commands.setTextSelection({ from: 1, to: editor.state.doc.content.size - 1 }); + await userEvent.click(screen.getByRole('button', { name: copy$() })); + await waitFor(() => expect(clipboard).toHaveLength(1)); + + expect(await (await clipboard[0].getType('text/plain')).text()).toBe('a b'); + }); }); describe('inserted by a transaction', () => { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/renderPassage.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/renderPassage.js index 5967a48456..a33720b308 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/renderPassage.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/renderPassage.js @@ -28,7 +28,7 @@ export const findChip = (editor, responseIdentifier) => findChipIn(editor.state.doc, responseIdentifier) || null; /** Mounts the real TipTapEditor through `useInlineChoicePassage`; resolves once it is ready. */ -export async function renderPassage({ value, errorResponseIdentifiers, mode = 'edit' }) { +export async function renderPassage({ value, errorResponseIdentifiers, onChange, mode = 'edit' }) { let passage; let provided; const modeRef = ref(mode); @@ -45,7 +45,7 @@ export async function renderPassage({ value, errorResponseIdentifiers, mode = 'e { components: { TipTapEditor, Panel }, setup() { - passage = useInlineChoicePassage({ errorResponseIdentifiers }); + passage = useInlineChoicePassage({ errorResponseIdentifiers, onChange }); return { extensions: passage.extensions, insertActions: passage.insertActions, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/useInlineChoicePassage.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/useInlineChoicePassage.spec.js index 5726183be6..0b3f20c02a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/useInlineChoicePassage.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/useInlineChoicePassage.spec.js @@ -1,5 +1,5 @@ import { nextTick } from 'vue'; -import { screen, waitFor } from '@testing-library/vue'; +import { fireEvent, screen, waitFor } from '@testing-library/vue'; import userEvent from '@testing-library/user-event'; import { qtiEditorStrings } from '../../../qtiEditorStrings'; import { getDropdowns } from '../parse'; @@ -171,3 +171,50 @@ describe('useInlineChoicePassage Insert action', () => { expect(editor.getHTML()).toBe(`

a ${chipHTML(ORIGINAL)} ${chipHTML(second)}b

`); }); }); + +describe('useInlineChoicePassage syncing', () => { + stubProseMirrorLayout(); + + const press = (editor, key, keyCode, init = {}) => + fireEvent.keyDown(editor.view.dom, { key, keyCode, ...init }); + + const VALUE = `

a${chip('r1', [['c1', 'x']], 'c1')}b

`; + + async function renderSynced() { + const onChange = jest.fn(); + const rendered = await renderPassage({ value: VALUE, onChange }); + rendered.editor.view.dom.focus(); + return { ...rendered, onChange }; + } + + it('reports the passage when a chip is removed, with no chip open', async () => { + const { editor, onChange } = await renderSynced(); + editor.commands.setTextSelection(3); + press(editor, 'Backspace', 8); + expect(onChange).toHaveBeenLastCalledWith('

ab

'); + }); + + it('reports the passage when undo restores a removed chip', async () => { + const { editor, onChange } = await renderSynced(); + editor.commands.setTextSelection(3); + press(editor, 'Backspace', 8); + press(editor, 'z', 90, { ctrlKey: true }); + expect(dropdowns(editor)).toHaveLength(1); + expect(onChange).toHaveBeenLastCalledWith(editor.getHTML()); + }); + + it('reports the passage when undo changes a closed chip’s options', async () => { + const { editor, onChange } = await renderSynced(); + editor.commands.updateInlineChoice('r1', { correctId: null }); + onChange.mockClear(); + press(editor, 'z', 90, { ctrlKey: true }); + expect(dropdowns(editor)[0].correctId).toBe('c1'); + expect(onChange).toHaveBeenLastCalledWith(editor.getHTML()); + }); + + it('leaves typing that changes no chip to be reported on blur', async () => { + const { editor, onChange } = await renderSynced(); + editor.commands.insertContentAt(1, 'z'); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/validation.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/validation.spec.js index 0ff4c8540b..ea80acf3b6 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/validation.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/validation.spec.js @@ -77,11 +77,12 @@ describe('validateInlineChoiceInteraction', () => { expect(errors).toEqual([{ code: ValidationError.EMPTY_CHOICE_CONTENT, id: 'b' }]); }); - it('reports a single empty option with no correct answer as exactly two errors', () => { + it('reports a single empty option with no correct answer as exactly three errors', () => { const errors = validate(withPassage(dropdown('r1', null, [{ id: 'a', text: '' }]))); expect(errors).toEqual([ { code: ValidationError.EMPTY_CHOICE_CONTENT, id: 'a' }, { code: ValidationError.NO_CORRECT_ANSWER, id: 'r1' }, + { code: ValidationError.TOO_FEW_CHOICES, id: 'r1' }, ]); }); }); @@ -172,11 +173,32 @@ describe('validateInlineChoiceInteraction', () => { it('is reported per dropdown', () => { const errors = validate( withPassage( - dropdown('r1', 'a', [{ id: 'a', text: 'blue' }]), - dropdown('r2', null, [{ id: 'c', text: 'red' }]), + dropdown('r1', 'a', [ + { id: 'a', text: 'blue' }, + { id: 'b', text: 'green' }, + ]), + dropdown('r2', null, [ + { id: 'c', text: 'red' }, + { id: 'd', text: 'grey' }, + ]), ), ); expect(errors).toEqual([{ code: ValidationError.NO_CORRECT_ANSWER, id: 'r2' }]); }); }); + + describe('TOO_FEW_CHOICES', () => { + it('flags a dropdown with one option by its response id', () => { + const errors = validate(withPassage(dropdown('r1', 'a', [{ id: 'a', text: 'blue' }]))); + expect(errors).toEqual([{ code: ValidationError.TOO_FEW_CHOICES, id: 'r1' }]); + }); + + it('comes after a missing correct answer, as the options panel shows them', () => { + const errors = validate(withPassage(dropdown('r1', null, [{ id: 'a', text: 'blue' }]))); + expect(errors).toEqual([ + { code: ValidationError.NO_CORRECT_ANSWER, id: 'r1' }, + { code: ValidationError.TOO_FEW_CHOICES, id: 'r1' }, + ]); + }); + }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/components/InlineChoiceChip/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/components/InlineChoiceChip/index.vue index 4cdeefad77..ceb344eef8 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/components/InlineChoiceChip/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/components/InlineChoiceChip/index.vue @@ -11,7 +11,11 @@ 'chip', { 'is-open': isEditable && isOpen, + 'is-revealed': !isEditable && showAnswers, + 'is-concealed': isConcealed, 'has-errors': hasErrors, + // TipTap marks a chip selected whenever the selection covers it, text selections + // included. 'is-selected': isEditable && selected, }, $computedClass({ ':focus': $coreOutline }), @@ -23,7 +27,10 @@ {{ $formatNumber(optionCount) }} {{ label }} @@ -48,6 +55,7 @@ import { NodeViewWrapper } from '@tiptap/vue-2'; import { qtiEditorStrings } from '../../../../qtiEditorStrings'; import { injectPassageChips } from '../../passageChips'; + import { copiedDropdown } from '../../parse'; export default { name: 'InlineChoiceChip', @@ -57,14 +65,21 @@ }, setup(props) { - const { openResponseIdentifier, openDropdown, isEditable, errorResponseIdentifiers } = - injectPassageChips(); + const { + openResponseIdentifier, + openDropdown, + isEditable, + errorResponseIdentifiers, + showAnswers, + } = injectPassageChips(); const { addAnswers$, answerDropdownWithCorrect$, answerDropdownNoCorrect$, answerDropdownWithCorrectNeedsAttention$, answerDropdownNoCorrectNeedsAttention$, + answerDropdownHidden$, + chooseAnswer$, } = qtiEditorStrings; const responseIdentifier = computed(() => props.node.attrs.responseIdentifier); @@ -81,9 +96,18 @@ const hasErrors = computed(() => errorResponseIdentifiers.value.includes(responseIdentifier.value), ); - const label = computed(() => (hasAnswer.value ? correctText.value : addAnswers$())); + // A preview that hides the answers shows what the learner sees before choosing. + const isConcealed = computed(() => !isEditable.value && !showAnswers.value); + const label = computed(() => { + if (isConcealed.value) return chooseAnswer$(); + return hasAnswer.value ? correctText.value : addAnswers$(); + }); // Starts with the visible label, so voice control finds the chip by what it shows. const accessibleName = computed(() => { + // A preview that hides the answers must not read them out either. + if (isConcealed.value) { + return answerDropdownHidden$({ label: label.value, count: optionCount.value }); + } const name = hasAnswer.value ? hasErrors.value ? answerDropdownWithCorrectNeedsAttention$ @@ -105,8 +129,8 @@ watchEffect( () => { if (!copySource.value) return; - const serializer = DOMSerializer.fromSchema(props.editor.schema); - copySource.value.replaceChildren(serializer.serializeNode(props.node)); + const { dom } = DOMSerializer.renderSpec(document, copiedDropdown(props.node.attrs)); + copySource.value.replaceChildren(dom); }, { flush: 'post' }, ); @@ -116,10 +140,12 @@ open: () => openDropdown(responseIdentifier.value), optionCount, hasAnswer, + isConcealed, hasErrors, label, accessibleName, isEditable, + showAnswers, isOpen, controlAttrs, }; @@ -130,10 +156,6 @@ type: Object, required: true, }, - editor: { - type: Object, - required: true, - }, selected: { type: Boolean, default: false, @@ -146,33 +168,49 @@ diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/components/InlineChoiceOptions/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/components/InlineChoiceOptions/index.vue new file mode 100644 index 0000000000..f118e1cc3a --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/components/InlineChoiceOptions/index.vue @@ -0,0 +1,514 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/historyShortcut.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/historyShortcut.js new file mode 100644 index 0000000000..635631ac19 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/historyShortcut.js @@ -0,0 +1,27 @@ +// The resolver does not read package `exports` subpaths. +// eslint-disable-next-line import/no-unresolved +import { keydownHandler } from '@tiptap/pm/keymap'; + +// The passage editor's own history shortcuts. `Mod` is Cmd on macOS and Ctrl elsewhere. +const HISTORY_KEYS = { 'Mod-z': 'undo', 'Shift-Mod-z': 'redo', 'Mod-y': 'redo' }; + +/** + * Which step of the passage's history a key press asks for, matched as the passage editor's + * keymap matches it, for controls that keep their keys from it: the chips and the options panel. + * + * @param {KeyboardEvent} event + * @returns {'undo'|'redo'|null} + */ +export function historyShortcut(event) { + let direction = null; + const bindings = {}; + for (const [key, step] of Object.entries(HISTORY_KEYS)) { + bindings[key] = () => { + direction = step; + return true; + }; + } + // The keymap hands its commands the view's state, which these do not use. + keydownHandler(bindings)({}, event); + return direction; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/parse.js index d5c84d10c0..cf8589ef31 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/parse.js @@ -10,6 +10,9 @@ export const OPTION = 'qti-inline-choice'; const PROMPT_MARKER = 'data-studio-prompt'; const SENTINEL_MARKER = 'data-studio-sentinel'; export const CORRECT_ATTR = 'data-studio-correct'; +const COPY_ATTR = 'data-studio-dropdown'; +/** A dropdown as `copiedDropdown` puts it on the clipboard. */ +export const COPIED_DROPDOWN = `${DROPDOWN}[${COPY_ATTR}]`; const SENTINEL_ID = 'studio_sentinel'; const KEPT_DROPDOWN_ATTRS = ['response-identifier', CORRECT_ATTR]; @@ -60,6 +63,43 @@ export function readDropdown(el) { }; } +/** + * A dropdown as it is copied, as a DOM output spec. An editor without dropdowns keeps only the + * text of an element it doesn't know, so the text is the correct answer alone, or nothing with no + * correct answer, and the dropdown itself is held in an attribute for a passage to read back. + * + * @param {InlineChoiceDropdown} dropdown + * @returns {Array} + */ +export function copiedDropdown({ responseIdentifier, options, correctId }) { + const correct = options.find(option => option.id === correctId); + const attrs = { [COPY_ATTR]: JSON.stringify({ responseIdentifier, options, correctId }) }; + return [DROPDOWN, attrs, correct ? correct.text : '']; +} + +/** + * @param {Element} el - A `COPIED_DROPDOWN`, from the clipboard + * @returns {InlineChoiceDropdown|false} false when it holds no dropdown with an option + */ +export function readCopiedDropdown(el) { + let copied; + try { + copied = JSON.parse(el.getAttribute(COPY_ATTR)); + } catch { + return false; + } + const isText = value => typeof value === 'string'; + const options = (Array.isArray(copied?.options) ? copied.options : []) + .filter(option => isText(option?.id) && isText(option?.text)) + .map(({ id, text }) => ({ id, text })); + if (!options.length) return false; + return { + responseIdentifier: isText(copied.responseIdentifier) ? copied.responseIdentifier : '', + options, + correctId: options.some(option => option.id === copied.correctId) ? copied.correctId : null, + }; +} + /** * @param {string[]} responseDeclarations * @returns {Map} response identifier → declared correct choice id diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/useInlineChoicePassage.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/useInlineChoicePassage.js index 7117a308ce..5660bb807c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/useInlineChoicePassage.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/useInlineChoicePassage.js @@ -2,10 +2,12 @@ import { computed, readonly, ref, unref } from 'vue'; // The resolver does not read package `exports` subpaths. // eslint-disable-next-line import/no-unresolved import { AllSelection } from '@tiptap/pm/state'; +import { findChildren } from '@tiptap/core'; import { qtiEditorStrings } from '../../qtiEditorStrings'; import { generateRandomSlug } from '../../utils/generateRandomSlug'; -import { InlineChoiceNode, findChip } from './InlineChoiceNode'; +import { InlineChoiceNode, findChip, isChip } from './InlineChoiceNode'; import { providePassageChips } from './passageChips'; +import { storedHTML } from 'shared/views/TipTapEditor/TipTapEditor/utils/imageSrc'; // Select-all selects the passage itself, where an inline node cannot go. Over a passage of one // paragraph, it is taken as that paragraph's text. @@ -16,20 +18,53 @@ function paragraphOfAllSelection({ doc, selection, schema }) { return { from: 1, to: doc.content.size - 1 }; } +/** Whether two documents hold the same chips, in the same order, with the same options. */ +function sameChips(prevDoc, doc) { + const before = findChildren(prevDoc, isChip); + const after = findChildren(doc, isChip); + return before.length === after.length && before.every(({ node }, i) => node.eq(after[i].node)); +} + +/** The response identifiers of the chips in `doc` that `prevDoc` lacks or holds otherwise. */ +function changedChips(prevDoc, doc) { + const before = new Map( + findChildren(prevDoc, isChip).map(({ node }) => [node.attrs.responseIdentifier, node]), + ); + return findChildren(doc, isChip) + .map(({ node }) => node) + .filter(node => !before.get(node.attrs.responseIdentifier)?.eq(node)) + .map(node => node.attrs.responseIdentifier); +} + /** * Everything a passage's `TipTapEditor` needs to hold inline choice dropdowns. Call from the * setup of the component that renders the editor, so the chips can reach what it provides. * `openResponseIdentifier` names the chip whose options panel is open, or is `null`. * `errorResponseIdentifiers` (array or ref) names the dropdowns with validation errors. + * `showAnswers` (boolean or ref) is whether a read-only passage shows each correct answer. + * + * `onChange` receives the passage's HTML after every change to its chips, and while a dropdown + * is open after every change at all, which `TipTapEditor` itself only reports on blur. The + * options panel, outside the editor, edits the passage through `updateDropdown` and has to show + * each edit, and each undo, as it happens; and the question's validation has to follow chips + * removed or restored in the passage. */ -export function useInlineChoicePassage({ errorResponseIdentifiers = [] } = {}) { +export function useInlineChoicePassage({ + errorResponseIdentifiers = [], + showAnswers = true, + onChange = () => {}, +} = {}) { const openResponseIdentifier = ref(null); // `setEditable` emits `update`; `editor.isEditable` itself is not reactive. const isEditable = ref(false); let editor = null; + const syncPassage = () => onChange(storedHTML(editor)); + function openDropdown(responseIdentifier) { openResponseIdentifier.value = responseIdentifier; + // The panel reads the dropdown from the synced passage, which may not hold it yet. + if (editor && responseIdentifier !== null) syncPassage(); } function focusChip(responseIdentifier) { @@ -39,6 +74,30 @@ export function useInlineChoicePassage({ errorResponseIdentifiers = [] } = {}) { else editor.commands.focus(); } + /** + * One step of the passage's history, so undo restores what the panel showed before it. + * + * @param {string} responseIdentifier + * @param {{ options?: Array<{id: string, text: string}>, correctId?: string|null }} change + * @returns {boolean} false when there is no such chip, or the change would leave it invalid + */ + function updateDropdown(responseIdentifier, change) { + return Boolean(editor?.commands.updateInlineChoice(responseIdentifier, change)); + } + + /** + * Steps through the passage's history, which holds the panel's edits too. A step that changes + * another chip than the open one opens that chip, where it can be seen. + */ + function stepHistory(direction) { + if (!editor) return; + const prevDoc = editor.state.doc; + editor.commands[direction](); + const open = openResponseIdentifier.value; + const changed = changedChips(prevDoc, editor.state.doc); + if (open !== null && changed.length && !changed.includes(open)) openDropdown(changed[0]); + } + const readonlyOpenResponseIdentifier = readonly(openResponseIdentifier); providePassageChips({ openResponseIdentifier: readonlyOpenResponseIdentifier, @@ -46,6 +105,7 @@ export function useInlineChoicePassage({ errorResponseIdentifiers = [] } = {}) { focusChip, isEditable: readonly(isEditable), errorResponseIdentifiers: computed(() => unref(errorResponseIdentifiers)), + showAnswers: computed(() => unref(showAnswers)), }); const node = InlineChoiceNode.extend({ @@ -58,9 +118,14 @@ export function useInlineChoicePassage({ errorResponseIdentifiers = [] } = {}) { }, onUpdate() { isEditable.value = this.editor.isEditable; + }, + onTransaction({ transaction }) { + if (!transaction.docChanged) return; + const isOpen = openResponseIdentifier.value !== null; + if (!isOpen && sameChips(transaction.before, transaction.doc)) return; + syncPassage(); // Undo, delete or cut can take the open chip out of the passage. - if (openResponseIdentifier.value === null) return; - if (!findChip(this.editor.state.doc, openResponseIdentifier.value)) openDropdown(null); + if (isOpen && !findChip(transaction.doc, openResponseIdentifier.value)) openDropdown(null); }, }); @@ -91,5 +156,8 @@ export function useInlineChoicePassage({ errorResponseIdentifiers = [] } = {}) { openResponseIdentifier: readonlyOpenResponseIdentifier, openDropdown, focusChip, + updateDropdown, + undo: () => stepHistory('undo'), + redo: () => stepHistory('redo'), }; } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/validation.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/validation.js index 225fe35df7..ebc0a6811a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/validation.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/validation.js @@ -1,6 +1,20 @@ import { ValidationError } from '../../constants'; import { getDropdowns } from './parse'; +/** + * What a dropdown as a whole lacks, rather than one of its options, in the order its options + * panel lists them; its chip shows the first. + * + * @param {{ options: Array, correctId: string|null }} dropdown + * @returns {string[]} ValidationError codes + */ +export function dropdownErrors({ options, correctId }) { + const codes = []; + if (correctId === null) codes.push(ValidationError.NO_CORRECT_ANSWER); + if (options.length < 2) codes.push(ValidationError.TOO_FEW_CHOICES); + return codes; +} + /** * Validate InlineChoiceState → ValidationError[]. Option errors carry the choice id, * dropdown errors the response identifier. @@ -15,7 +29,8 @@ export function validateInlineChoiceInteraction(state) { } const errors = []; - for (const { responseIdentifier, options, correctId } of dropdowns) { + for (const dropdown of dropdowns) { + const { responseIdentifier, options } = dropdown; const firstSeenId = new Map(); const duplicateIds = new Set(); @@ -37,8 +52,8 @@ export function validateInlineChoiceInteraction(state) { errors.push({ code: ValidationError.DUPLICATE_CHOICE_CONTENT, id }); } - if (correctId === null) { - errors.push({ code: ValidationError.NO_CORRECT_ANSWER, id: responseIdentifier }); + for (const code of dropdownErrors(dropdown)) { + errors.push({ code, id: responseIdentifier }); } } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js index a2324c9fcf..81dbe3707b 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js @@ -369,6 +369,11 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', { message: 'Choose a correct answer', context: 'Validation error when no choice is marked correct', }, + errorTooFewInlineChoiceOptions: { + message: 'Add at least 2 options', + context: + 'Validation error when an answer dropdown in an inline choice question has fewer than 2 options. Also shown on the dropdown itself', + }, errorTooManyCorrectAnswers: { message: 'Only one correct answer is allowed for single-choice questions.', context: 'Validation error when multiple choices are marked correct for single-select', @@ -551,4 +556,80 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', { context: "Accessible name of an answer dropdown chip in the passage that has no correct answer and has validation errors. {label} is the chip's 'Add answers' label", }, + answerDropdownHidden: { + message: '{label}, answer dropdown, {count, plural, one {# option} other {# options}}', + context: + "Accessible name of an answer dropdown chip in a question preview that hides the correct answers. {label} is the chip's 'Choose…' label", + }, + chooseAnswer: { + message: 'Choose…', + context: + 'Label on an answer dropdown in a question preview that hides the correct answers, where the learner would pick one', + }, + inlineChoiceLabel: { + message: 'Inline choice', + context: 'Display name for an inline choice question type shown in the question type selector', + }, + inlineChoiceDescription: { + message: 'Learners choose the correct answer from dropdowns placed within a passage.', + context: 'Description for the inline choice question type in the info modal', + }, + questionOptionalLabel: { + message: 'Question (optional)', + context: 'Section header for a question prompt that authors may leave empty', + }, + passageEditorLabel: { + message: 'Passage editor', + context: 'Section header for the passage that holds the inline choice dropdowns', + }, + passageLabel: { + message: 'Passage', + context: 'Section header for the passage of an inline choice question, as a preview shows it', + }, + passageEditorDescription: { + message: 'Inline choice widgets can be inserted blank or converted from highlighted text.', + context: 'Instruction under the passage editor header of an inline choice question', + }, + editPassageLabel: { + message: 'Edit passage', + context: 'Accessible label for the clickable region that opens the passage for editing', + }, + inlineChoiceOptionsLabel: { + message: 'Inline choice answer options', + context: 'Section header above the answer options of the open inline choice dropdown', + }, + inlineChoiceOptionsDialogLabel: { + message: 'Answer options for dropdown {number}', + context: + 'Accessible name of the panel that edits the options of one inline choice dropdown. {number} is its position in the passage', + }, + closeInlineChoiceOptions: { + message: 'Close answer options', + context: 'Button that closes the panel editing the options of an inline choice dropdown', + }, + inlineChoiceOptionLabel: { + message: 'Option {number}', + context: + 'Names an inline choice option row: its text field, its reorder controls, and the announcement after it moves', + }, + markInlineChoiceOptionCorrect: { + message: 'Mark option {number} as correct', + context: 'Accessible label for the radio button that marks an inline choice option correct', + }, + addInlineChoiceOptionBtn: { + message: 'Add option', + context: 'Button that appends a new option to the open inline choice dropdown', + }, + deleteInlineChoiceOptionBtn: { + message: 'Delete option {number}', + context: 'Accessible label for the delete icon button next to an inline choice option', + }, + errorInlineChoiceOptionProblems: { + message: 'One or more inline choice options have problems', + context: 'Validation error above the passage when any of its dropdowns needs attention', + }, + errorNoInlineChoice: { + message: 'Insert at least one inline choice', + context: 'Validation error when the passage of an inline choice question has no dropdown', + }, }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js index 01b0fbdeaa..77240d0e76 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js @@ -518,8 +518,8 @@ export const UNRECOGNIZED_INTERACTION_ITEM_DOCUMENT = ``; /** - * Inline choice dropdowns, which have a descriptor but no editor yet. The second dropdown - * has no correct answer, which the editor's rules would report. + * Inline choice dropdowns. The second dropdown has no correct answer, which the editor's + * rules report. */ export const INLINE_CHOICE_ITEM_DOCUMENT = ` Date: Mon, 19 Oct 2026 10:59:29 -0500 Subject: [PATCH 07/17] Make inline choice chips easier to use with a keyboard or screen reader - Left and Right on a focused chip put the caret back in the passage beside it, following the text direction, as they move past the chip there. - A chip the caret crosses is announced by its accessible name, since a screen reader reads out the text the caret crosses and a chip has none. Removing chips is announced too. Both use KDS's assertive live region, so they are read as a crossed character would be. - A chip that needs attention shows the error icon in place of its option count, as an option row does, so colour is not all that marks it. Its name says so in a preview that hides the answers too. - The passage editor is described by its helper text. - A chip without a correct answer shows what its options panel says is missing, such as "Choose a correct answer", so the two always match. A chip with one shows it, its error icon saying if it needs work. - Undo and redo from the options panel or a focused chip don't scroll the page away from the panel. - The options panel's messages are no alerts, which would all be read at once each time it opens. Those it opens with describe the dialog and its fields; one that appears while it is open is announced once, through KDS's live region. - A chip's count badge is hidden from assistive technology, which hears the count in its name, so that the name starts with the text it shows. The chip's label and accessible name move to passageChips.js, so the chip and the announcements name it the same way. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../components/ValidationMessage/index.vue | 10 +- .../interactions/inlineChoice/Editor.vue | 5 + .../inlineChoice/__tests__/Editor.spec.js | 86 ++++++++- .../__tests__/InlineChoiceChip.spec.js | 172 ++++++++++++++++-- .../inlineChoice/__tests__/renderPassage.js | 2 + .../__tests__/useInlineChoicePassage.spec.js | 138 +++++++++++++- .../components/InlineChoiceChip/index.vue | 128 ++++++++----- .../components/InlineChoiceOptions/index.vue | 56 ++++-- .../interactions/inlineChoice/passageChips.js | 72 ++++++++ .../inlineChoice/useInlineChoicePassage.js | 123 ++++++++++++- .../views/QTIEditor/qtiEditorStrings.js | 29 ++- 11 files changed, 723 insertions(+), 98 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ValidationMessage/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ValidationMessage/index.vue index 4df5bae320..2f917c3353 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ValidationMessage/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ValidationMessage/index.vue @@ -15,7 +15,7 @@ @@ -55,6 +55,14 @@ type: Boolean, default: false, }, + /** + * Whether an error is an alert, read out as it appears. Off when the component showing + * it announces its messages itself. + */ + alert: { + type: Boolean, + default: true, + }, }, }; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/Editor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/Editor.vue index 98a160deb2..9e07e4cbc7 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/Editor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/Editor.vue @@ -65,6 +65,7 @@
@@ -135,6 +136,7 @@ import ClickableRegion from '../../components/ClickableRegion/index.vue'; import AnswerSettings from '../choice/components/AnswerSettings/index.vue'; import { hasRichTextContent } from '../../utils/richText'; + import { generateRandomSlug } from '../../utils/generateRandomSlug'; import { useInlineChoicePassage } from './useInlineChoicePassage'; import InlineChoiceOptions from './components/InlineChoiceOptions/index.vue'; import TipTapEditor from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditor'; @@ -186,10 +188,12 @@ .map(d => d.responseIdentifier); }); + const passageDescriptionId = generateRandomSlug('passage-description'); const passage = useInlineChoicePassage({ errorResponseIdentifiers, // An author editing the question sees its answers, even in the closed passage. showAnswers: computed(() => props.mode === 'edit' || props.showAnswers), + describedBy: passageDescriptionId, onChange: setPassage, }); @@ -321,6 +325,7 @@ state, errors, passage, + passageDescriptionId, hasPrompt, hasDropdownErrors, hasNoDropdown, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/Editor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/Editor.spec.js index 168131323b..04e31c6403 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/Editor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/Editor.spec.js @@ -23,9 +23,10 @@ jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { }); // `useDraggableUniverse` destructures this composable, so the automock has to return a // usable object rather than undefined. +const mockSendAssertiveMessage = jest.fn(); jest.mock('kolibri-design-system/lib/composables/useKLiveRegion', () => ({ __esModule: true, - default: () => ({ sendPoliteMessage: jest.fn() }), + default: () => ({ sendPoliteMessage: jest.fn(), sendAssertiveMessage: mockSendAssertiveMessage }), })); const PASSAGE = `

The Earth ${chip('r1', [ @@ -131,6 +132,12 @@ describe('InlineChoiceEditor', () => { expect(passage.getAllByRole('button', CHIP)).toHaveLength(2); }); + it('describes the passage editor with its helper text', async () => { + await renderEditor(); + const passage = section(tr.passageEditorLabel$()).querySelector('.ProseMirror'); + expect(passage).toHaveAccessibleDescription(tr.passageEditorDescription$()); + }); + it('opens on the question, focused, with the passage closed', async () => { render(InlineChoiceEditor, { props: { @@ -235,7 +242,8 @@ describe('InlineChoiceEditor', () => { ); expect(optionInputs().map(input => input.value)).toEqual(['Sun', 'Stars']); - expect(chips()[0]).toHaveTextContent(/^2/); + // The chip needs a correct answer, so its count is in its name, beside the error icon. + expect(chips()[0]).toHaveAccessibleName(/2 options/); expect(savedDropdowns(emitted())[0].options.map(o => o.text)).toEqual(['Sun', 'Stars']); }); @@ -314,7 +322,7 @@ describe('InlineChoiceEditor', () => { screen.getByRole('button', { name: tr.deleteInlineChoiceOptionBtn$({ number: 1 }) }), ); - expect(chips()[1]).toHaveTextContent(tr.addAnswers$()); + expect(chips()[1]).toHaveTextContent(tr.errorNoCorrectAnswer$()); expect(savedDropdowns(emitted())[1].correctId).toBeNull(); }); @@ -737,6 +745,43 @@ describe('InlineChoiceEditor', () => { }); describe('validation', () => { + it('reads the problems a chip opens with as the panel’s description, not as alerts', async () => { + const { user } = await renderEditor({ + interaction: interactionOf({ passage: `

The ${chip('r1', [['sun', '']])}

` }), + }); + mockSendAssertiveMessage.mockClear(); + await openChip(user, 0); + expect(dialog()).toHaveAccessibleDescription( + `${tr.errorNoCorrectAnswer$()} ${tr.errorTooFewInlineChoiceOptions$()}`, + ); + expect(within(dialog()).queryAllByRole('alert')).toHaveLength(0); + expect(mockSendAssertiveMessage).not.toHaveBeenCalled(); + }); + + it('announces a problem that appears while the panel is open', async () => { + const { user } = await renderEditor(); + await openChip(user, 1); + mockSendAssertiveMessage.mockClear(); + await user.click( + screen.getByRole('button', { name: tr.deleteInlineChoiceOptionBtn$({ number: 2 }) }), + ); + expect(within(dialog()).getByText(tr.errorTooFewInlineChoiceOptions$())).toBeInTheDocument(); + expect(mockSendAssertiveMessage).toHaveBeenCalledWith(tr.errorTooFewInlineChoiceOptions$()); + + // The option Add option focuses reads its own error, so it is not announced as well. + mockSendAssertiveMessage.mockClear(); + await user.click(screen.getByRole('button', { name: tr.addInlineChoiceOptionBtn$() })); + await waitFor(() => expect(optionInputs()[1]).toHaveFocus()); + expect(optionInputs()[1]).toHaveAccessibleDescription(tr.errorEmptyChoiceContent$()); + expect(mockSendAssertiveMessage).not.toHaveBeenCalled(); + + // An error that appears as the author types is announced, once for rows that share it. + await user.keyboard('revolves'); + expect(within(dialog()).getAllByText(tr.errorDuplicateChoiceContent$())).toHaveLength(2); + expect(mockSendAssertiveMessage).toHaveBeenCalledTimes(1); + expect(mockSendAssertiveMessage).toHaveBeenCalledWith(tr.errorDuplicateChoiceContent$()); + }); + it('asks for a second option on a dropdown with one in its panel', async () => { const { emitted, user } = await renderEditor({ interaction: interactionOf({ @@ -744,11 +789,30 @@ describe('InlineChoiceEditor', () => { }), }); expect(emitted()['update:errors'].at(-1)[0]).toEqual([{ code: 'TOO_FEW_CHOICES', id: 'r1' }]); + // A marked answer still shows, as beside a blank option; the error icon flags the chip. + expect(chips()[0]).toHaveTextContent(/^Sun$/); + expect(chips()[0].querySelector('svg')).toBeInTheDocument(); + expect(chips()[0]).toHaveAccessibleName(/needs attention/); await openChip(user, 0); expect(within(dialog()).getByText(tr.errorTooFewInlineChoiceOptions$())).toBeInTheDocument(); }); + it('shows on a chip the first of the problems its panel lists', async () => { + const { user } = await renderEditor({ + interaction: interactionOf({ passage: `

The ${chip('r1', [['sun', 'Sun']])}

` }), + }); + expect(chips()[0]).toHaveTextContent(new RegExp(`^${tr.errorNoCorrectAnswer$()}$`)); + + await openChip(user, 0); + const problems = within(dialog()) + .getAllByText( + new RegExp(`^(${tr.errorNoCorrectAnswer$()}|${tr.errorTooFewInlineChoiceOptions$()})$`), + ) + .map(el => el.textContent.trim()); + expect(problems).toEqual([tr.errorNoCorrectAnswer$(), tr.errorTooFewInlineChoiceOptions$()]); + }); + it('marks the question incomplete and flags a chip without a correct answer', async () => { const { emitted, user } = await renderEditor(); expect(emitted()['update:errors'].at(-1)[0]).toEqual([ @@ -759,6 +823,10 @@ describe('InlineChoiceEditor', () => { ).toBeInTheDocument(); expect(chips()[0]).toHaveAccessibleName(/needs attention/); expect(chips()[1]).not.toHaveAccessibleName(/needs attention/); + // The error icon stands in for the option count, as on an option row. + expect(chips()[0]).toHaveTextContent(new RegExp(`^${tr.errorNoCorrectAnswer$()}$`)); + expect(chips()[0].querySelector('svg')).toBeInTheDocument(); + expect(chips()[1]).toHaveTextContent('2 revolves'); await openChip(user, 0); expect(within(dialog()).getByText(tr.errorNoCorrectAnswer$())).toBeInTheDocument(); @@ -846,5 +914,17 @@ describe('InlineChoiceEditor', () => { tr.answerDropdownHidden$({ label: tr.chooseAnswer$(), count: 2 }), ); }); + + it('marks a hidden chip that needs attention by more than colour', async () => { + await renderEditor({ mode: 'view', showAnswers: false }); + const [incomplete, complete] = screen.getAllByRole('img', { name: /answer dropdown/ }); + expect(incomplete).toHaveAccessibleName( + tr.answerDropdownHiddenNeedsAttention$({ label: tr.chooseAnswer$(), count: 3 }), + ); + // The error icon stands in for its option count. + expect(incomplete).toHaveTextContent(new RegExp(`^${tr.chooseAnswer$()}$`)); + expect(incomplete.querySelector('svg')).toBeInTheDocument(); + expect(complete.querySelector('svg')).not.toBeInTheDocument(); + }); }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/InlineChoiceChip.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/InlineChoiceChip.spec.js index a25bf6512f..4bd8a0cef8 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/InlineChoiceChip.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/InlineChoiceChip.spec.js @@ -16,6 +16,12 @@ const getChips = () => screen.getAllByRole('button', CHIP); const chipPos = (editor, responseIdentifier) => findChip(editor, responseIdentifier).pos; +// A valid dropdown has at least two options; `a` is the one these tests mark correct. +const TWO_OPTIONS = [ + ['a', 'x'], + ['b', 'w'], +]; + describe('InlineChoiceChip', () => { stubProseMirrorLayout(); @@ -39,9 +45,16 @@ describe('InlineChoiceChip', () => { expect(el).toHaveAccessibleName(/^Moon, /); }); + // The chip says what its options panel says is missing. it.each([ - ['options have text but none is correct', [['a', 'Sun']], undefined, 1], - ['no option has text', [['a', '']], undefined, 0], + [ + 'options have text but none is correct', + [['a', 'Sun']], + undefined, + 1, + qtiEditorStrings.errorNoCorrectAnswer$(), + ], + ['no option has text', [['a', '']], undefined, 0, qtiEditorStrings.errorNoCorrectAnswer$()], [ 'the correct option has blank text', [ @@ -50,14 +63,13 @@ describe('InlineChoiceChip', () => { ], 'a', 1, + qtiEditorStrings.errorEmptyChoiceContent$(), ], - ])('prompts for answers when %s', async (_, options, correct, count) => { + ])('prompts for an answer when %s', async (_, options, correct, count, label) => { await renderPassage({ value: `

${chip('r1', options, correct)}

` }); const el = getChip(); - expect(el).toHaveTextContent(`${count} ${qtiEditorStrings.addAnswers$()}`); - expect(el).toHaveAccessibleName( - qtiEditorStrings.answerDropdownNoCorrect$({ count, label: qtiEditorStrings.addAnswers$() }), - ); + expect(el).toHaveTextContent(`${count} ${label}`); + expect(el).toHaveAccessibleName(qtiEditorStrings.answerDropdownNoCorrect$({ count, label })); }); it('is a button that opens a dialog, closed until opened', async () => { @@ -149,6 +161,125 @@ describe('InlineChoiceChip', () => { expect(editor.state.selection.head).toBe(3); }); + it.each([ + ['{ArrowRight}', 3], + ['{ArrowLeft}', 2], + ])('puts the caret beside a focused chip with %s', async (key, caret) => { + const user = userEvent.setup(); + const { editor, openResponseIdentifier } = await renderPassage({ + value: `

a${chip('r1', [['c1', 'x']], 'c1')}b

`, + }); + getChip().focus(); + await user.keyboard(key); + + await waitFor(() => expect(editor.view.dom).toHaveFocus()); + expect(editor.state.selection.empty).toBe(true); + expect(editor.state.selection.head).toBe(caret); + expect(openResponseIdentifier.value).toBeNull(); + }); + + it('puts the caret by the text direction in right-to-left text', async () => { + const user = userEvent.setup(); + const { editor } = await renderPassage({ + value: `

a${chip('r1', [['c1', 'x']], 'c1')}b

`, + }); + const chipEl = getChip(); + const { getComputedStyle } = window; + const spy = jest + .spyOn(window, 'getComputedStyle') + .mockImplementation((el, ...rest) => + el === chipEl ? { direction: 'rtl' } : getComputedStyle.call(window, el, ...rest), + ); + try { + chipEl.focus(); + await user.keyboard('{ArrowLeft}'); + } finally { + spy.mockRestore(); + } + + await waitFor(() => expect(editor.view.dom).toHaveFocus()); + expect(editor.state.selection.head).toBe(3); + }); + + it('leaves a focused chip with a modified arrow key alone', async () => { + const user = userEvent.setup(); + const { editor } = await renderPassage({ + value: `

a${chip('r1', [['c1', 'x']], 'c1')}b

`, + }); + getChip().focus(); + await user.keyboard('{Shift>}{ArrowRight}{/Shift}'); + expect(getChip()).toHaveFocus(); + expect(editor.view.dom).not.toHaveFocus(); + }); + + // The chip keeps its keys from ProseMirror, so it runs the passage's history shortcuts itself. + it.each([ + ['Ctrl+Shift+Z', '{Control>}{Shift>}z{/Shift}{/Control}'], + ['Ctrl+Y', '{Control>}y{/Control}'], + ])('undoes with Ctrl+Z and redoes with %s while focused', async (_, redoKeys) => { + const user = userEvent.setup(); + const { editor } = await renderPassage({ value: `

${chip('r1', TWO_OPTIONS, 'a')}

` }); + editor.commands.updateInlineChoice('r1', { + options: [ + { id: 'a', text: 'x' }, + { id: 'c', text: 'z' }, + { id: 'b', text: 'y' }, + ], + correctId: 'b', + }); + await waitFor(() => expect(getChip()).toHaveTextContent('3 y')); + getChip().focus(); + + await user.keyboard('{Control>}z{/Control}'); + await waitFor(() => expect(getChip()).toHaveTextContent('2 x')); + expect(getChip()).toHaveFocus(); + + await user.keyboard(redoKeys); + await waitFor(() => expect(getChip()).toHaveTextContent('3 y')); + expect(getChip()).toHaveFocus(); + }); + + // As the passage's keymap does, by the key's place when the layout gives a non-Latin letter. + it('undoes and redoes by the key’s place on a non-Latin keyboard layout', async () => { + const { editor } = await renderPassage({ value: `

${chip('r1', TWO_OPTIONS, 'a')}

` }); + editor.commands.updateInlineChoice('r1', { correctId: null }); + await waitFor(() => expect(getChip()).not.toHaveTextContent('x')); + const press = init => fireEvent.keyDown(getChip(), { ctrlKey: true, ...init }); + + await press({ key: 'я', keyCode: 90 }); + await waitFor(() => expect(getChip()).toHaveTextContent('x')); + await press({ key: 'Я', keyCode: 90, shiftKey: true }); + await waitFor(() => expect(getChip()).not.toHaveTextContent('x')); + await press({ key: 'я', keyCode: 90 }); + await waitFor(() => expect(getChip()).toHaveTextContent('x')); + await press({ key: 'н', keyCode: 89 }); + await waitFor(() => expect(getChip()).not.toHaveTextContent('x')); + }); + + // jsdom is no Mac, so the passage's `Mod` is Ctrl, and Cmd+Z is no shortcut there. + it('leaves alone a history key the passage would not take', async () => { + const user = userEvent.setup(); + const { editor } = await renderPassage({ value: `

${chip('r1', [['a', 'x']], 'a')}

` }); + editor.commands.updateInlineChoice('r1', { correctId: null }); + await waitFor(() => expect(getChip()).not.toHaveTextContent('x')); + getChip().focus(); + + await user.keyboard('{Meta>}z{/Meta}'); + expect(editor.can().undo()).toBe(true); + expect(getChip()).not.toHaveTextContent('x'); + }); + + it('puts focus in the passage when an undo takes the focused chip away', async () => { + const user = userEvent.setup(); + const { editor } = await renderPassage({ value: '

ab

' }); + editor.chain().setTextSelection(2).insertInlineChoice('r1').run(); + getChip().focus(); + + await user.keyboard('{Control>}z{/Control}'); + expect(screen.queryByRole('button', CHIP)).not.toBeInTheDocument(); + await waitFor(() => expect(editor.view.dom).toHaveFocus()); + }); + it('marks the chips a text selection covers, so the highlight can cover them', async () => { const { editor } = await renderPassage({ value: `

a${chip('r1', [['c1', 'x']], 'c1')}b${chip('r2', [['c2', 'y']], 'c2')}

`, @@ -164,13 +295,13 @@ describe('InlineChoiceChip', () => { it('deletes a chip with Backspace and restores it with undo', async () => { const { editor } = await renderPassage({ - value: `

a${chip('r1', [['c1', 'x']], 'c1')}b

`, + value: `

a${chip('r1', TWO_OPTIONS, 'a')}b

`, }); editor.commands.setTextSelection(3); press(editor, 'Backspace', 8); await waitFor(() => expect(screen.queryByRole('button', CHIP)).not.toBeInTheDocument()); press(editor, 'z', 90, { ctrlKey: true }); - await waitFor(() => expect(getChip()).toHaveTextContent('1 x')); + await waitFor(() => expect(getChip()).toHaveTextContent('2 x')); }); it('focuses a chip by its response identifier, or the editor once it is gone', async () => { @@ -205,30 +336,43 @@ describe('InlineChoiceChip', () => { expect(onMinimize).not.toHaveBeenCalled(); }); + // So speech input finds the chip by what it shows; its count is in its name, so not read twice. + it('starts its name with the text it shows to assistive technology', async () => { + await renderPassage({ value: `

${chip('r1', TWO_OPTIONS, 'a')}

` }); + const el = getChip(); + const shown = [...el.querySelectorAll('*')] + .filter(child => !child.children.length && !child.closest('[aria-hidden="true"]')) + .map(child => child.textContent.trim()) + .filter(Boolean) + .join(' '); + expect(shown).toBe('x'); + expect(el.getAttribute('aria-label').startsWith(shown)).toBe(true); + }); + it('shows errors while its response identifier is in the error list', async () => { const errorResponseIdentifiers = ref([]); await renderPassage({ - value: `

${chip('r1', [['a', 'x']], 'a')}

`, + value: `

${chip('r1', TWO_OPTIONS, 'a')}

`, errorResponseIdentifiers, }); const el = getChip(); expect(el).not.toHaveClass('has-errors'); expect(el).toHaveAccessibleName( - qtiEditorStrings.answerDropdownWithCorrect$({ count: 1, label: 'x' }), + qtiEditorStrings.answerDropdownWithCorrect$({ count: 2, label: 'x' }), ); errorResponseIdentifiers.value = ['r1']; await nextTick(); expect(el).toHaveClass('has-errors'); expect(el).toHaveAccessibleName( - qtiEditorStrings.answerDropdownWithCorrectNeedsAttention$({ count: 1, label: 'x' }), + qtiEditorStrings.answerDropdownWithCorrectNeedsAttention$({ count: 2, label: 'x' }), ); errorResponseIdentifiers.value = []; await nextTick(); expect(el).not.toHaveClass('has-errors'); expect(el).toHaveAccessibleName( - qtiEditorStrings.answerDropdownWithCorrect$({ count: 1, label: 'x' }), + qtiEditorStrings.answerDropdownWithCorrect$({ count: 2, label: 'x' }), ); }); @@ -240,7 +384,7 @@ describe('InlineChoiceChip', () => { expect(getChip()).toHaveAccessibleName( qtiEditorStrings.answerDropdownNoCorrectNeedsAttention$({ count: 0, - label: qtiEditorStrings.addAnswers$(), + label: qtiEditorStrings.errorNoCorrectAnswer$(), }), ); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/renderPassage.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/renderPassage.js index a33720b308..4730e94f46 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/renderPassage.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/renderPassage.js @@ -71,6 +71,8 @@ export async function renderPassage({ value, errorResponseIdentifiers, onChange, editor: onReady.mock.calls[0][0], openResponseIdentifier: passage.openResponseIdentifier, focusChip: provided.focusChip, + undo: passage.undo, + redo: passage.redo, onMinimize, setMode: newMode => { modeRef.value = newMode; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/useInlineChoicePassage.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/useInlineChoicePassage.spec.js index 0b3f20c02a..e831b59f66 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/useInlineChoicePassage.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/useInlineChoicePassage.spec.js @@ -1,6 +1,7 @@ import { nextTick } from 'vue'; import { fireEvent, screen, waitFor } from '@testing-library/vue'; import userEvent from '@testing-library/user-event'; +import useKLiveRegion from 'kolibri-design-system/lib/composables/useKLiveRegion'; import { qtiEditorStrings } from '../../../qtiEditorStrings'; import { getDropdowns } from '../parse'; import { CHIP, chip, renderPassage } from './renderPassage'; @@ -18,6 +19,14 @@ jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { }; }); +jest.mock('kolibri-design-system/lib/composables/useKLiveRegion', () => { + const sendAssertiveMessage = jest.fn(); + return { + __esModule: true, + default: () => ({ sendPoliteMessage: jest.fn(), sendAssertiveMessage }), + }; +}); + const dropdowns = editor => getDropdowns(editor.getHTML()); const chipHTML = ({ responseIdentifier, options, correctId }) => @@ -28,8 +37,8 @@ const chipHTML = ({ responseIdentifier, options, correctId }) => ); // The handler only trusts the selection once the author has focused the editor. -async function renderAt(value, from, to = from) { - const rendered = await renderPassage({ value }); +async function renderAt(value, from, to = from, passageOptions = {}) { + const rendered = await renderPassage({ value, ...passageOptions }); rendered.editor.view.dom.focus(); rendered.editor.commands.setTextSelection({ from, to }); await nextTick(); @@ -60,7 +69,7 @@ describe('useInlineChoicePassage Insert action', () => { expect(editor.getHTML()).toBe(`

The ${chipHTML(dropdown)}rises

`); expect(openResponseIdentifier.value).toBe(dropdown.responseIdentifier); const el = await screen.findByRole('button', CHIP); - expect(el).toHaveTextContent(`0 ${qtiEditorStrings.addAnswers$()}`); + expect(el).toHaveTextContent(`0 ${qtiEditorStrings.errorNoCorrectAnswer$()}`); await waitFor(() => expect(el).toHaveAttribute('aria-expanded', 'true')); }); @@ -172,6 +181,129 @@ describe('useInlineChoicePassage Insert action', () => { }); }); +describe('useInlineChoicePassage announcements', () => { + stubProseMirrorLayout(); + + const { sendAssertiveMessage } = useKLiveRegion(); + beforeEach(() => sendAssertiveMessage.mockClear()); + + /** Puts the caret where a test starts, leaving out what getting it there announced. */ + async function renderAnnouncerAt(...args) { + const rendered = await renderAt(...args); + sendAssertiveMessage.mockClear(); + return rendered; + } + + const announced = () => sendAssertiveMessage.mock.calls.map(([message]) => message); + + // ProseMirror reads `keyCode`, which `userEvent.keyboard` does not set. + const press = (editor, key, keyCode) => fireEvent.keyDown(editor.view.dom, { key, keyCode }); + + const TWO_CHIPS = `

a${chip( + 'r1', + [ + ['c1', 'x'], + ['c4', 'w'], + ], + 'c1', + )}b${chip('r2', [ + ['c2', 'y'], + ['c3', 'z'], + ])}

`; + + it.each([ + ['forward', 2, 3], + ['backward', 3, 2], + ])('names a chip the caret moves %s across', async (_, from, to) => { + const { editor } = await renderAnnouncerAt(TWO_CHIPS, from); + editor.commands.setTextSelection(to); + expect(announced()).toEqual([ + qtiEditorStrings.answerDropdownWithCorrect$({ label: 'x', count: 2 }), + ]); + }); + + it('names a chip that needs attention as such', async () => { + const { editor } = await renderAnnouncerAt(TWO_CHIPS, 4, 4, { + errorResponseIdentifiers: ['r2'], + }); + editor.commands.setTextSelection(5); + expect(announced()).toEqual([ + qtiEditorStrings.answerDropdownNoCorrectNeedsAttention$({ + label: qtiEditorStrings.errorNoCorrectAnswer$(), + count: 2, + }), + ]); + }); + + it('says nothing about a caret move across text', async () => { + const { editor } = await renderAnnouncerAt(TWO_CHIPS, 1); + editor.commands.setTextSelection(2); + expect(announced()).toEqual([]); + }); + + it('says which chip Backspace removed', async () => { + const { editor } = await renderAnnouncerAt(TWO_CHIPS, 3); + press(editor, 'Backspace', 8); + expect(announced()).toEqual([qtiEditorStrings.answerDropdownRemoved$({ label: 'x' })]); + }); + + it('counts the chips removed together', async () => { + const { editor } = await renderAnnouncerAt(TWO_CHIPS, 1, 6); + editor.commands.deleteSelection(); + expect(announced()).toEqual([qtiEditorStrings.answerDropdownsRemoved$({ count: 2 })]); + }); + + // The options panel and a focused chip step through the passage's history from outside it. + it('says which chip an undo or redo from outside the passage removed', async () => { + const { editor, undo, redo } = await renderPassage({ value: TWO_CHIPS }); + editor.commands.deleteRange({ from: 2, to: 3 }); + undo(); + expect(announced()).toEqual([]); + redo(); + expect(announced()).toEqual([qtiEditorStrings.answerDropdownRemoved$({ label: 'x' })]); + }); + + it('says so when an undo from a focused chip removes it', async () => { + const user = userEvent.setup(); + const { editor } = await renderPassage({ value: '

ab

' }); + editor.chain().setTextSelection(2).insertInlineChoice('r1').run(); + screen.getByRole('button', CHIP).focus(); + + await user.keyboard('{Control>}z{/Control}'); + expect(announced()).toEqual([ + qtiEditorStrings.answerDropdownRemoved$({ label: qtiEditorStrings.errorNoCorrectAnswer$() }), + ]); + }); + + it('says nothing about changes made while the passage is not focused', async () => { + const { editor } = await renderPassage({ value: TWO_CHIPS }); + editor.commands.setTextSelection(2); + editor.commands.setTextSelection(3); + editor.commands.setContent('

ab

'); + expect(announced()).toEqual([]); + }); +}); + +describe('useInlineChoicePassage history from outside the passage', () => { + stubProseMirrorLayout(); + + // The options panel steps through the passage's history; scrolling to the step would move the + // page away from the panel. + it('undoes and redoes without scrolling to the change', async () => { + const { editor, undo, redo } = await renderPassage({ + value: `

a${chip('r1', [['c1', 'x']], 'c1')}b

`, + }); + editor.commands.deleteRange({ from: 2, to: 3 }); + const steps = []; + editor.on('transaction', ({ transaction }) => steps.push(transaction)); + + undo(); + redo(); + expect(steps.filter(step => step.docChanged)).toHaveLength(2); + expect(steps.some(step => step.scrolledIntoView)).toBe(false); + }); +}); + describe('useInlineChoicePassage syncing', () => { stubProseMirrorLayout(); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/components/InlineChoiceChip/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/components/InlineChoiceChip/index.vue index ceb344eef8..e24aa0656d 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/components/InlineChoiceChip/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/components/InlineChoiceChip/index.vue @@ -22,8 +22,22 @@ ]" data-copy-ignore @click="isEditable && open()" + @keydown="isEditable && onKeydown($event)" > - + + + + props.node.attrs.responseIdentifier); - const optionCount = computed( - () => props.node.attrs.options.filter(option => option.text.trim()).length, - ); - const correctText = computed(() => { - const correct = props.node.attrs.options.find( - option => option.id === props.node.attrs.correctId, - ); - return correct ? correct.text.trim() : ''; - }); - const hasAnswer = computed(() => correctText.value !== ''); const hasErrors = computed(() => errorResponseIdentifiers.value.includes(responseIdentifier.value), ); // A preview that hides the answers shows what the learner sees before choosing. const isConcealed = computed(() => !isEditable.value && !showAnswers.value); - const label = computed(() => { - if (isConcealed.value) return chooseAnswer$(); - return hasAnswer.value ? correctText.value : addAnswers$(); - }); - // Starts with the visible label, so voice control finds the chip by what it shows. - const accessibleName = computed(() => { - // A preview that hides the answers must not read them out either. - if (isConcealed.value) { - return answerDropdownHidden$({ label: label.value, count: optionCount.value }); - } - const name = hasAnswer.value - ? hasErrors.value - ? answerDropdownWithCorrectNeedsAttention$ - : answerDropdownWithCorrect$ - : hasErrors.value - ? answerDropdownNoCorrectNeedsAttention$ - : answerDropdownNoCorrect$; - return name({ label: label.value, count: optionCount.value }); - }); + const description = computed(() => + describeChip(props.node.attrs, { + hasErrors: hasErrors.value, + isConcealed: isConcealed.value, + }), + ); + const optionCount = computed(() => description.value.optionCount); + const hasAnswer = computed(() => description.value.hasAnswer); + const label = computed(() => description.value.label); + const accessibleName = computed(() => description.value.accessibleName); const isOpen = computed(() => openResponseIdentifier.value === responseIdentifier.value); const controlAttrs = computed(() => @@ -135,7 +122,44 @@ { flush: 'post' }, ); + /** + * Left and Right on the focused chip put the caret back in the passage beside it, as they + * move past the chip there. They go by the text's direction, so in right-to-left text + * Left lands after the chip. + */ + function leaveForText(event) { + if (!['ArrowLeft', 'ArrowRight'].includes(event.key)) return; + if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return; + const pos = props.getPos(); + if (typeof pos !== 'number') return; + event.preventDefault(); + const rtl = window.getComputedStyle(event.currentTarget).direction === 'rtl'; + const after = (event.key === 'ArrowRight') !== rtl; + props.editor + .chain() + .setTextSelection(after ? pos + props.node.nodeSize : pos) + .focus() + .run(); + } + + /** The passage's undo and redo, as its keys never reach ProseMirror from the chip. */ + function stepHistory(event) { + const direction = historyShortcut(event); + if (!direction) return; + event.preventDefault(); + const button = event.currentTarget; + stepPassageHistory(direction); + // An undo can take the chip away, and focus with it. + if (!button.isConnected) focusChip(responseIdentifier.value); + } + + function onKeydown(event) { + leaveForText(event); + stepHistory(event); + } + return { + onKeydown, copySource, open: () => openDropdown(responseIdentifier.value), optionCount, @@ -156,10 +180,18 @@ type: Object, required: true, }, + editor: { + type: Object, + required: true, + }, selected: { type: Boolean, default: false, }, + getPos: { + type: Function, + required: true, + }, }, }; @@ -216,6 +248,7 @@ // TipTap only keeps ProseMirror out when the button itself is the event target. .badge, + .error-icon, .label { pointer-events: none; } @@ -243,6 +276,13 @@ } } + .error-icon { + top: 0; + flex-shrink: 0; + width: 1.5em; + height: 1.5em; + } + .badge { display: inline-flex; flex-shrink: 0; @@ -255,10 +295,6 @@ color: var(--tokens-textInverted); background-color: var(--tokens-correct); border-radius: 0.75em; - - .has-errors & { - background-color: v-bind('$themeTokens.error'); - } } // A selected chip takes the selection's colour (KDS's global `::selection`), which the browser diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/components/InlineChoiceOptions/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/components/InlineChoiceOptions/index.vue index f118e1cc3a..1652d26760 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/components/InlineChoiceOptions/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/components/InlineChoiceOptions/index.vue @@ -12,14 +12,18 @@ role="dialog" aria-modal="true" :aria-label="inlineChoiceOptionsDialogLabel$({ number })" + :aria-describedby="problems.length ? problems.map((_, i) => problemId(i)).join(' ') : null" class="options-panel" @focusout="onFocusout" @keydown="onHistoryKeydown" @keydown.esc.stop="$emit('close')" > + {{ problem }} @@ -127,6 +131,7 @@ {{ errorEmptyChoiceContent$() }} @@ -134,6 +139,7 @@ {{ errorDuplicateChoiceContent$() }} @@ -157,7 +163,8 @@ + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js index 7905175f8a..a2840fab32 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js @@ -643,4 +643,68 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', { message: 'Insert at least one inline choice', context: 'Validation error when the passage of an inline choice question has no dropdown', }, + changeQuestionTypeTitle: { + message: 'Change question type?', + context: + 'Title of the dialog asking an author to confirm a change of question type that would remove or change some of the question', + }, + changeQuestionTypeIntro: { + message: 'Switching to {type} will change this question:', + context: + "Opens the list of what changing the question's type would remove or change. {type} is the new question type, e.g. 'Numeric input'", + }, + changeQuestionTypeAction: { + message: 'Change', + context: 'Button that confirms changing the question type', + }, + cancelAction: { + message: 'Cancel', + context: 'Button that closes a dialog without doing anything', + }, + answersRemovedLoss: { + message: 'All answers will be removed.', + context: + 'Listed when changing the question type: the new type has no place for the answers the question has', + }, + incorrectAnswersRemovedLoss: { + message: 'Answers not marked as correct will be removed.', + context: + 'Listed when changing a choice question to a type whose answers are all correct, such as numeric input or text entry', + }, + anyCorrectAnswerAcceptedLoss: { + message: 'Learners will only need to give one of the correct answers.', + context: + 'Listed when changing a choice question with several correct answers to numeric input or text entry, where any one accepted answer is enough', + }, + correctAnswersReducedLoss: { + message: 'Only the first correct answer will stay marked as correct.', + context: + 'Listed when changing a question with several correct answers to single choice, which can only have one', + }, + correctAnswersNotKeptLoss: { + message: 'Answers will no longer be marked as correct.', + context: + 'Listed when changing a choice question to ordering, where an answer is not correct or incorrect by itself', + }, + orderBecomesAnswerLoss: { + message: 'The current order will become the correct order.', + context: 'Listed when changing a question to ordering: the answers keep their current order', + }, + orderNotKeptLoss: { + message: "The correct order won't be kept.", + context: + 'Listed when changing an ordering question to a choice question, which has no correct order', + }, + formattingRemovedLoss: { + message: + 'Formatting, images and formulas in answers will be removed. Answers with only an image or formula will be removed entirely.', + context: + 'Listed when changing the question type to one whose answers can only hold text, such as text entry', + }, + passageAddedToQuestionLoss: { + message: + 'The passage will be added to the question, with each blank shown as "{blank}". Its answers will be removed.', + context: + 'Listed when changing an inline choice question to a type with no passage. {blank} is what each blank becomes, a row of underscores', + }, }); From ea20a783834aab5bfb7a63bb09baf66218efd84e Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Tue, 13 Oct 2026 10:23:22 -0500 Subject: [PATCH 14/17] Focus a question's editor only when its card opens Each interaction editor opened and focused its question as soon as it mounted in edit mode. That suited a card being opened, but any other mount took focus too: an editor remounted for a new question type pulled focus away from the type selector the author was using. Focus is now asked for, not implied. An editor exposes focus(), which opens its question for editing and so focuses it, and the card calls it, through InteractionSection, when it opens or is created open: the moments it already scrolls itself into view. An editor that only mounts leaves its question closed and focus where it was. The card asks after its section has re-rendered, so an editor that opens its question only in edit mode, as associate and match do, has heard of the mode change by then. TipTapEditor, switched into edit mode before it is ready, now focuses once it is. Its content only joins the page then, and a focus before that was lost: a new question's editor opened its question at once, but the question took no focus. Editors used to be created with autofocus already set, which TipTap applies once ready, so nothing hit this before. A test over the editor registry checks that every editor focuses its question when asked, so a new one can't miss it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../components/InteractionSection/index.vue | 18 ++++++- .../__tests__/QTIItemEditor.spec.js | 32 +++++++++++- .../components/QTIItemEditor/index.vue | 15 ++++-- .../interactions/__tests__/editors.spec.js | 49 +++++++++++++++++++ .../interactions/__tests__/renderFocused.js | 21 ++++++++ .../interactions/associate/Editor.vue | 8 +-- .../associate/__tests__/Editor.spec.js | 28 ++++++++--- .../QTIEditor/interactions/choice/Editor.vue | 10 ++-- .../choice/__tests__/Editor.spec.js | 30 +++++++++--- .../interactions/inlineChoice/Editor.vue | 10 ++-- .../inlineChoice/__tests__/Editor.spec.js | 22 ++++++++- .../QTIEditor/interactions/match/Editor.vue | 8 +-- .../match/__tests__/Editor.spec.js | 28 ++++++++--- .../interactions/ordering/Editor.vue | 7 ++- .../ordering/__tests__/Editor.spec.js | 33 ++++++++++--- .../interactions/textEntry/Editor.vue | 7 ++- .../textEntry/__tests__/Editor.spec.js | 33 ++++++++++--- .../TipTapEditor/TipTapEditor.vue | 41 ++++++++++++++-- .../__tests__/TipTapEditor.spec.js | 49 +++++++++++++++++++ 19 files changed, 380 insertions(+), 69 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/editors.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/renderFocused.js diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue index 81a97ccf4a..f1479853ce 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue @@ -18,6 +18,7 @@ - import { computed, watch } from 'vue'; + import { computed, nextTick, ref, watch } from 'vue'; import useInteractionDescriptor from '../../composables/useInteractionDescriptor'; import QuestionTypeSelector from '../QuestionTypeSelector/index.vue'; import { generateRandomSlug } from '../../utils/generateRandomSlug'; @@ -86,7 +87,22 @@ const editorComponent = computed(() => editors[descriptor.value.type]); + const editor = ref(null); + + /** + * Opens the question for editing, which focuses it. After this section's next render, so + * the editor has heard of a mode change the card made just before asking. + */ + async function focus() { + await nextTick(); + editor.value?.focus(); + } + return { + editor, + // Public: lets the card focus the question when it opens. + // eslint-disable-next-line vue/no-unused-properties + focus, descriptor, editorComponent, questionType, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js index e8bb4eaf37..b5a4b5ef8c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js @@ -1,4 +1,4 @@ -import { render, screen, fireEvent } from '@testing-library/vue'; +import { render, screen, fireEvent, waitFor } from '@testing-library/vue'; import { nextTick } from 'vue'; import VueRouter from 'vue-router'; import QTIItemEditor from '../index.vue'; @@ -212,6 +212,34 @@ describe('QTIItemEditor', () => { }); }); + describe('focusing an opened card', () => { + // The mock rich text editor renders a