From 1c8ccad59b261f3982035bfe306bc0aab5b01a46 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Wed, 14 Oct 2026 11:40:47 -0500 Subject: [PATCH 1/9] Upgrade KDS to 5.10.0 Co-Authored-By: Claude Opus 5.5 (1M context) --- package.json | 2 +- pnpm-lock.yaml | 24 +++++++++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index e771aa3003..99829c14bc 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "jspdf": "https://github.com/parallax/jsPDF.git#b7a1d8239c596292ce86dafa77f05987bcfa2e6e", "jszip": "^3.10.1", "kolibri-constants": "^0.2.12", - "kolibri-design-system": "5.9.0", + "kolibri-design-system": "5.10.0", "lodash": "^4.18.1", "lowlight": "^3.3.0", "marked": "^16.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 792bbbcebd..372e25f772 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,8 +93,8 @@ importers: specifier: ^0.2.12 version: 0.2.12 kolibri-design-system: - specifier: 5.9.0 - version: 5.9.0 + specifier: 5.10.0 + version: 5.10.0 lodash: specifier: ^4.18.1 version: 4.18.1 @@ -5330,8 +5330,17 @@ packages: kolibri-constants@0.2.12: resolution: {integrity: sha512-ApVc/KLwEaDJohqKhQTdao4UdWmoyq2pQ5lk8ra+1rDpJvsFWsAOGVC4RTv4YEDlAYJzj2/QZlJQ91u5yUURSQ==} - kolibri-design-system@5.9.0: - resolution: {integrity: sha512-/2Nr7q23kQgQcTk4ylU9x0qe5HDqgk/Mq+WZPIeGm22oAkrozPO7TxLT4yaUfOWgPVICWBGky1jZuBDsPCQbDQ==} + kolibri-css-variables@1.0.0: + resolution: {integrity: sha512-aqcUsqEWiKEqulOjAW+p752HZYJZ0CkbSmbhzojcok2WCN8OgU4CVFAbLpTuMWyAOL1JbGFTMZh1TYA9xHm5qA==} + engines: {node: '>=20.19.0'} + peerDependencies: + kolibri-design-system: '>=5.9.0' + peerDependenciesMeta: + kolibri-design-system: + optional: true + + kolibri-design-system@5.10.0: + resolution: {integrity: sha512-AVpIkYf2nm5RxQkWHhD8sB7maPqaj8WGZBwVr6IyvGUvysfJ3Zv9XwlyWcnrkY91xwB4gsfYsXscdnvbzyYpDg==} kolibri-format@1.0.1: resolution: {integrity: sha512-yGQpsJkBAzmRueAq6MG1UOuDl9pbhEtMWNxq9ObG5pPVkG8uhWJAS1L71GCuNAeaV1XG2IWo2565Ov4yXnudeA==} @@ -14210,7 +14219,11 @@ snapshots: kolibri-constants@0.2.12: {} - kolibri-design-system@5.9.0: + kolibri-css-variables@1.0.0(kolibri-design-system@5.10.0): + optionalDependencies: + kolibri-design-system: 5.10.0 + + kolibri-design-system@5.10.0: dependencies: aphrodite: https://codeload.github.com/learningequality/aphrodite/tar.gz/fdc8d7be8912a5cf17f74ff10f124013c52c3e32 autosize: 3.0.21 @@ -14219,6 +14232,7 @@ snapshots: date-fns: 1.30.1 frame-throttle: 3.0.0 fuzzysearch: 1.0.3 + kolibri-css-variables: 1.0.0(kolibri-design-system@5.10.0) lodash: 4.17.23 node-glob: 1.2.0 popper.js: 1.16.1 From b1b9ace64365fc2f21ceb03691fdfaca2476452a Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Wed, 7 Oct 2026 14:42:30 -0500 Subject: [PATCH 2/9] 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 8278c26045..51c7415275 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 cee17841e5..e4d67dbeb2 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 fd0420d113975f3f5cec01d92fdf6c366f261521 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Wed, 7 Oct 2026 14:42:32 -0500 Subject: [PATCH 3/9] 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 | 37 ++++++++++++++++--- .../__tests__/EditorToolbar.spec.js | 31 ++++++++++++++++ 3 files changed, 72 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..7fbcba56c3 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue @@ -135,18 +135,22 @@ - + @@ -165,6 +169,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..e536315892 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/InlineChoiceNode.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/InlineChoiceNode.js @@ -18,7 +18,7 @@ import { CORRECT_ATTR, DROPDOWN, OPTION, 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( 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..a5aac19efa --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/Editor.spec.js @@ -0,0 +1,821 @@ +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']], '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']); + }); + + 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']); + }); + }); + + 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' }]); + }); + + 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(2); + await waitFor(() => expect(optionInputs()[1]).toHaveFocus()); + await user.keyboard('rotates'); + expect(chips()[1]).toHaveTextContent(/^2/); + expect(savedDropdowns(emitted())[1].options.map(o => o.text)).toEqual([ + 'revolves', + '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(); + await openChip(user, 1); + 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(['rotates', 'revolves']); + expect( + screen.getByRole('radio', { name: tr.markInlineChoiceOptionCorrect$({ number: 2 }) }), + ).toBeChecked(); + const [, dropdown] = savedDropdowns(emitted()); + expect(dropdown.options.map(o => o.text)).toEqual(['rotates', 'revolves']); + 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()[1]).toHaveFocus()); + + await user.keyboard('{Control>}z{/Control}'); + expect(optionInputs().map(input => input.value)).toEqual(['revolves']); + await waitFor(() => expect(optionInputs()[0]).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']); + + await user.keyboard('{Control>}{Shift>}z{/Shift}{/Control}'); + expect(optionInputs().map(input => input.value)).toEqual(['revolvess']); + 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'])); + 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('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()[1]).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()[1]).toHaveAttribute('aria-invalid', 'true'); + expect(within(dialog()).getByText(tr.errorEmptyChoiceContent$())).toBeInTheDocument(); + expect(optionInputs()[1]).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(`1 ${tr.chooseAnswer$()}`); + expect(hidden).toHaveAccessibleName( + tr.answerDropdownHidden$({ label: tr.chooseAnswer$(), count: 1 }), + ); + }); + }); +}); 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/components/InlineChoiceChip/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/components/InlineChoiceChip/index.vue index 4cdeefad77..0e84cb2010 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,10 @@ 'chip', { 'is-open': isEditable && isOpen, + 'is-revealed': !isEditable && showAnswers, '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 +26,10 @@ {{ $formatNumber(optionCount) }} {{ label }} @@ -57,14 +63,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 +94,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$ @@ -116,10 +138,12 @@ open: () => openDropdown(responseIdentifier.value), optionCount, hasAnswer, + isConcealed, hasErrors, label, accessibleName, isEditable, + showAnswers, isOpen, controlAttrs, }; @@ -146,33 +170,40 @@ 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..67d46fc574 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/components/InlineChoiceOptions/index.vue @@ -0,0 +1,505 @@ + + + + + + + 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/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/qtiEditorStrings.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js index f0ccd108fd..8655d04626 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js @@ -545,4 +545,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 4096e3684e..5e29787330 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js @@ -450,8 +450,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: Wed, 7 Oct 2026 15:09:11 -0500 Subject: [PATCH 8/9] 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. 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) --- .../interactions/inlineChoice/Editor.vue | 5 + .../inlineChoice/__tests__/Editor.spec.js | 27 +++- .../__tests__/InlineChoiceChip.spec.js | 118 +++++++++++++++++ .../inlineChoice/__tests__/renderPassage.js | 2 + .../__tests__/useInlineChoicePassage.spec.js | 109 +++++++++++++++- .../components/InlineChoiceChip/index.vue | 121 +++++++++++------- .../interactions/inlineChoice/passageChips.js | 51 ++++++++ .../inlineChoice/useInlineChoicePassage.js | 117 +++++++++++++++-- .../views/QTIEditor/qtiEditorStrings.js | 16 +++ 9 files changed, 508 insertions(+), 58 deletions(-) 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 a5aac19efa..85696ec9fa 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 @@ -25,7 +25,7 @@ jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { // usable object rather than undefined. jest.mock('kolibri-design-system/lib/composables/useKLiveRegion', () => ({ __esModule: true, - default: () => ({ sendPoliteMessage: jest.fn() }), + default: () => ({ sendPoliteMessage: jest.fn(), sendAssertiveMessage: jest.fn() }), })); const PASSAGE = `

The Earth ${chip('r1', [ @@ -124,6 +124,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: { @@ -224,7 +230,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']); }); @@ -730,6 +737,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.addAnswers$()}$`)); + expect(chips()[0].querySelector('svg')).toBeInTheDocument(); + expect(chips()[1]).toHaveTextContent('1 revolves'); await openChip(user, 0); expect(within(dialog()).getByText(tr.errorNoCorrectAnswer$())).toBeInTheDocument(); @@ -817,5 +828,17 @@ describe('InlineChoiceEditor', () => { tr.answerDropdownHidden$({ label: tr.chooseAnswer$(), count: 1 }), ); }); + + 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..169ad82569 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 @@ -149,6 +149,124 @@ 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', [['a', 'x']], 'a')}

` }); + editor.commands.updateInlineChoice('r1', { + options: [ + { id: 'a', text: 'x' }, + { id: 'b', text: 'y' }, + ], + correctId: 'b', + }); + await waitFor(() => expect(getChip()).toHaveTextContent('2 y')); + getChip().focus(); + + await user.keyboard('{Control>}z{/Control}'); + await waitFor(() => expect(getChip()).toHaveTextContent('1 x')); + expect(getChip()).toHaveFocus(); + + await user.keyboard(redoKeys); + await waitFor(() => expect(getChip()).toHaveTextContent('2 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', [['a', 'x']], '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')}

`, 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..71c230f419 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(); @@ -172,6 +181,102 @@ 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']], '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: 1 }), + ]); + }); + + 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.addAnswers$(), + 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.addAnswers$() }), + ]); + }); + + 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 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 0e84cb2010..c90c475fe1 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 @@ -21,8 +21,19 @@ ]" data-copy-ignore @click="isEditable && open()" + @keydown="isEditable && onKeydown($event)" > - + + + {{ $formatNumber(optionCount) }} 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(() => @@ -133,7 +117,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, @@ -162,6 +183,10 @@ type: Boolean, default: false, }, + getPos: { + type: Function, + required: true, + }, }, }; @@ -209,6 +234,7 @@ // TipTap only keeps ProseMirror out when the button itself is the event target. .badge, + .error-icon, .label { pointer-events: none; } @@ -236,6 +262,13 @@ } } + .error-icon { + top: 0; + flex-shrink: 0; + width: 1.5em; + height: 1.5em; + } + .badge { display: inline-flex; flex-shrink: 0; @@ -248,10 +281,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 colours (`::selection` in `shared/styles`), which the diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/passageChips.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/passageChips.js index 2c53312e21..c4a32ede65 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/passageChips.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/passageChips.js @@ -1,4 +1,5 @@ import { inject, provide } from 'vue'; +import { qtiEditorStrings } from '../../qtiEditorStrings'; const PassageChipsSymbol = Symbol('inlineChoicePassageChips'); @@ -9,3 +10,53 @@ export function providePassageChips(chips) { export function injectPassageChips() { return inject(PassageChipsSymbol); } + +/** + * What a chip shows and is called, shared by the chip and by what the passage announces about + * it, so a screen reader hears a chip named the same way wherever it meets it. + * + * @param {{ options: Array<{ id: string, text: string }>, correctId: string|null }} attrs - The + * chip node's attributes + * @param {object} [state] + * @param {boolean} [state.hasErrors] - Whether the dropdown has validation errors + * @param {boolean} [state.isConcealed] - Whether a preview hides the answers + * @returns {{ label: string, optionCount: number, hasAnswer: boolean, accessibleName: string }} + */ +export function describeChip( + { options, correctId }, + { hasErrors = false, isConcealed = false } = {}, +) { + const { + addAnswers$, + answerDropdownWithCorrect$, + answerDropdownNoCorrect$, + answerDropdownWithCorrectNeedsAttention$, + answerDropdownNoCorrectNeedsAttention$, + answerDropdownHidden$, + answerDropdownHiddenNeedsAttention$, + chooseAnswer$, + } = qtiEditorStrings; + + const optionCount = options.filter(option => option.text.trim()).length; + const correct = options.find(option => option.id === correctId); + const correctText = correct ? correct.text.trim() : ''; + const hasAnswer = correctText !== ''; + + if (isConcealed) { + // A preview that hides the answers must not read them out either, but still says which + // dropdowns need work, as their colour does. + const label = chooseAnswer$(); + const name = hasErrors ? answerDropdownHiddenNeedsAttention$ : answerDropdownHidden$; + return { label, optionCount, hasAnswer, accessibleName: name({ label, count: optionCount }) }; + } + + const label = hasAnswer ? correctText : addAnswers$(); + // Starts with the visible label, so voice control finds the chip by what it shows. + let name; + if (hasAnswer) { + name = hasErrors ? answerDropdownWithCorrectNeedsAttention$ : answerDropdownWithCorrect$; + } else { + name = hasErrors ? answerDropdownNoCorrectNeedsAttention$ : answerDropdownNoCorrect$; + } + return { label, optionCount, hasAnswer, accessibleName: name({ label, count: optionCount }) }; +} 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 5660bb807c..cff7fc8803 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/useInlineChoicePassage.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/useInlineChoicePassage.js @@ -1,12 +1,13 @@ 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 { AllSelection, Plugin } from '@tiptap/pm/state'; import { findChildren } from '@tiptap/core'; +import useKLiveRegion from 'kolibri-design-system/lib/composables/useKLiveRegion'; import { qtiEditorStrings } from '../../qtiEditorStrings'; import { generateRandomSlug } from '../../utils/generateRandomSlug'; import { InlineChoiceNode, findChip, isChip } from './InlineChoiceNode'; -import { providePassageChips } from './passageChips'; +import { describeChip, 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 @@ -18,6 +19,21 @@ function paragraphOfAllSelection({ doc, selection, schema }) { return { from: 1, to: doc.content.size - 1 }; } +/** + * The chip a caret move went straight over, or null. The caret passes a chip in one step, and + * a screen reader reads out the text the caret crosses, but there is none in a chip. + */ +function crossedChip(prevState, state) { + const before = prevState.selection; + const after = state.selection; + if (!before.empty || !after.empty || before.head === after.head) return null; + const from = Math.min(before.head, after.head); + const node = state.doc.nodeAt(from); + return node && isChip(node) && from + node.nodeSize === Math.max(before.head, after.head) + ? node + : null; +} + /** Whether two documents hold the same chips, in the same order, with the same options. */ function sameChips(prevDoc, doc) { const before = findChildren(prevDoc, isChip); @@ -36,22 +52,58 @@ function changedChips(prevDoc, doc) { .map(node => node.attrs.responseIdentifier); } +/** The chips in `prevDoc` that `doc` no longer holds; a moved chip keeps its identifier. */ +function removedChips(prevDoc, doc) { + const kept = new Set(findChildren(doc, isChip).map(({ node }) => node.attrs.responseIdentifier)); + return findChildren(prevDoc, isChip) + .map(({ node }) => node) + .filter(node => !kept.has(node.attrs.responseIdentifier)); +} + +/** + * @typedef {object} InlineChoicePassageObject + * @property {import('@tiptap/core').AnyExtension[]} extensions - For the passage's + * `TipTapEditor` `extensions` prop: the inline choice node. + * @property {object[]} insertActions - For its `insertActions` prop: Insert, which adds a chip + * and opens it. + * @property {Readonly>} openResponseIdentifier - The response + * identifier of the chip whose options panel is open, or `null`. + * @property {(responseIdentifier: ?string) => void} openDropdown - Opens a chip's options + * panel, or closes it with `null`. + * @property {(responseIdentifier: string) => void} focusChip - Focuses a chip's button, or the + * editor when the chip has left the passage. + * @property {(responseIdentifier: string, change: { options?: Array<{ id: string, text: string }>, + * correctId?: ?string }) => boolean} updateDropdown - Applies an edit from the options panel + * as one step of the passage's history; `false` when there is no such chip, or the change + * would leave it invalid. + * @property {() => void} undo - Undoes a step of the passage's history, and opens the chip it + * changed when that is not the open one. + * @property {() => void} redo - Redoes a step of the passage's history, as `undo` does. + */ + /** * 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. + * @param {object} [options] + * @param {string[] | import('vue').Ref} [options.errorResponseIdentifiers] - The + * response identifiers of the dropdowns with validation errors. + * @param {boolean | import('vue').Ref} [options.showAnswers] - Whether a read-only + * passage shows each correct answer. + * @param {?string} [options.describedBy] - The id of the passage's helper text, which + * describes the editor. + * @param {(html: string) => void} [options.onChange] - Receives the passage's HTML, as above. + * @returns {InlineChoicePassageObject} What the passage's editor and options panel use. */ export function useInlineChoicePassage({ errorResponseIdentifiers = [], showAnswers = true, + describedBy = null, onChange = () => {}, } = {}) { const openResponseIdentifier = ref(null); @@ -85,30 +137,79 @@ export function useInlineChoicePassage({ return Boolean(editor?.commands.updateInlineChoice(responseIdentifier, change)); } + // A step taken from the options panel or a focused chip, outside the passage's focus. + let isSteppingFromOutside = false; + /** - * 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. + * Steps through the passage's history, which holds the panel's edits too, for the panel and + * the chips, which keep their keys from the passage. 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](); + isSteppingFromOutside = true; + try { + editor.commands[direction](); + } finally { + isSteppingFromOutside = false; + } const open = openResponseIdentifier.value; const changed = changedChips(prevDoc, editor.state.doc); if (open !== null && changed.length && !changed.includes(open)) openDropdown(changed[0]); } + const isInError = responseIdentifier => + unref(errorResponseIdentifiers).includes(responseIdentifier); + + /** What a screen reader should hear about the chips a change crossed or removed, if any. */ + function chipMessage(prevState, state) { + if (state.doc.eq(prevState.doc)) { + const crossed = crossedChip(prevState, state); + if (!crossed) return null; + const hasErrors = isInError(crossed.attrs.responseIdentifier); + return describeChip(crossed.attrs, { hasErrors }).accessibleName; + } + const removed = removedChips(prevState.doc, state.doc); + if (removed.length > 1) + return qtiEditorStrings.answerDropdownsRemoved$({ count: removed.length }); + if (!removed.length) return null; + const { label } = describeChip(removed[0].attrs); + return qtiEditorStrings.answerDropdownRemoved$({ label }); + } + + // Assertive, so it is read as a crossed character would be, rather than after it. + const { sendAssertiveMessage } = useKLiveRegion(); + const announcer = new Plugin({ + view: () => ({ + update(view, prevState) { + // Only what the author does in the passage, not what loading or another field does to it. + if (!view.editable || !(view.hasFocus() || isSteppingFromOutside)) return; + const message = chipMessage(prevState, view.state); + if (message) sendAssertiveMessage(message); + }, + }), + }); + + const description = new Plugin({ + props: { attributes: describedBy ? { 'aria-describedby': describedBy } : {} }, + }); + const readonlyOpenResponseIdentifier = readonly(openResponseIdentifier); providePassageChips({ openResponseIdentifier: readonlyOpenResponseIdentifier, openDropdown, focusChip, + stepHistory, isEditable: readonly(isEditable), errorResponseIdentifiers: computed(() => unref(errorResponseIdentifiers)), showAnswers: computed(() => unref(showAnswers)), }); const node = InlineChoiceNode.extend({ + addProseMirrorPlugins() { + return [...this.parent(), announcer, description]; + }, onCreate() { editor = this.editor; isEditable.value = editor.isEditable; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js index 8655d04626..673a2f73ef 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js @@ -545,11 +545,27 @@ 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", }, + answerDropdownRemoved: { + message: '{label}, answer dropdown, removed', + context: + "Announced to screen readers when an answer dropdown is deleted from the passage. {label} is what the chip showed: its correct answer, or 'Add answers'", + }, + answerDropdownsRemoved: { + message: '{count, plural, one {# answer dropdown removed} other {# answer dropdowns removed}}', + context: + 'Announced to screen readers when several answer dropdowns are deleted from the passage at once', + }, 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", }, + answerDropdownHiddenNeedsAttention: { + message: + '{label}, answer dropdown, needs attention, {count, plural, one {# option} other {# options}}', + context: + "Accessible name of an answer dropdown chip with a problem to fix, in a question preview that hides the correct answers. {label} is the chip's 'Choose…' label", + }, chooseAnswer: { message: 'Choose…', context: From bceea4a76f19c1c394cbf90b29d5575b204c3311 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Wed, 14 Oct 2026 11:41:29 -0500 Subject: [PATCH 9/9] Show the insert icon on the inline choice Insert action Co-Authored-By: Claude Opus 5.5 (1M context) --- .../interactions/inlineChoice/useInlineChoicePassage.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 cff7fc8803..78b60466ac 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/useInlineChoicePassage.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/useInlineChoicePassage.js @@ -233,7 +233,7 @@ export function useInlineChoicePassage({ const insertAction = { name: InlineChoiceNode.name, title: qtiEditorStrings.insertInlineChoice$(), - icon: 'add', + icon: 'insert', prominent: true, // Before focus, the chip goes at the end of the passage, which always takes one. // Replacing a multi-line selection with an inline node would join the lines.