diff --git a/.stylelintrc.js b/.stylelintrc.js index 9b7b1077f7..84cbb1dc6c 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -9,6 +9,8 @@ module.exports = { */ 'selector-max-id': null, // This would require a major refactor 'csstree/validator': null, // this triggers issues with unknown at rules too. + // Allows uppercase, as in KDS theme variables like --tokens-fineLine. + 'custom-property-pattern': '^([a-z][a-zA-Z0-9]*)(-[a-zA-Z0-9]+)*$', 'selector-pseudo-element-no-unknown': [ true, { diff --git a/contentcuration/contentcuration/frontend/shared/styles/vuetify.scss b/contentcuration/contentcuration/frontend/shared/styles/vuetify.scss index 393ac10d48..64daeaa167 100644 --- a/contentcuration/contentcuration/frontend/shared/styles/vuetify.scss +++ b/contentcuration/contentcuration/frontend/shared/styles/vuetify.scss @@ -2479,10 +2479,15 @@ video { [aria-disabled] { cursor: default; } +/* Custom properties, so elements the browser paints no selection on can match it */ +:root { + --selection-color: #000000; + --selection-background-color: #b3d4fc; +} ::selection { - color: #000000; + color: var(--selection-color); text-shadow: none; - background-color: #b3d4fc; + background-color: var(--selection-background-color); } .bottom-sheet-transition-enter, .bottom-sheet-transition-leave-to { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/README.md b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/README.md index 95a7373557..c446a26809 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/README.md +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/README.md @@ -33,3 +33,6 @@ Prompts, choices, items and hints are edited in `TipTapEditor` with `format="htm > In `parse`, read any field TipTap loads as HTML with `getContentHTML` (or `getPromptHTML`) from `serialization/xml.js`, never `innerHTML` or `XMLSerializer`. XML serialization self-closes empty elements (``); the HTML parser doesn't treat `/>` as closing, so everything after it is nested inside and lost. > > On build, pass the HTML to `buildXmlNode({ innerHTML })`, which parses it as HTML and re-creates it as XML. + +> [!NOTE] +> A node view that shows itself as selected, like the inline choice chip, uses `--selection-background-color` and `--selection-color`, the colours of Studio's `::selection` (`shared/styles/vuetify.scss`), not a theme colour. KDS's global styles select with `$themeBrand.secondary.v_100`, but Studio doesn't load them as of KDS 5.9.0. When it does, point those variables at KDS's colour. diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js index 58d645f9be..a24333f30f 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js @@ -82,8 +82,10 @@ describe('validateQtiItem', () => { expect(validateQtiItem(xml, { allowFreeResponse: false })).toEqual([]); }); - it('does not apply the editor rules to an interaction with no editor', () => { - expect(validateQtiItem(INLINE_CHOICE_ITEM_DOCUMENT)).toEqual([]); + it('runs the inline choice validator', () => { + expect(validateQtiItem(INLINE_CHOICE_ITEM_DOCUMENT)).toEqual([ + { code: ValidationError.NO_CORRECT_ANSWER, id: 'RESPONSE_2' }, + ]); }); it('reports an item with no raw data at all', () => { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js index 71643822a7..83c931c7b7 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js @@ -144,6 +144,29 @@ describe('InteractionSection', () => { }); }); + describe('switching to inline choice', () => { + it('keeps the prompt as the question and shows the passage editor', async () => { + const { emitted } = render(SwitchableSection, { routes: new VueRouter() }); + await nextTick(); + + await fireEvent.click(screen.getAllByText(tr.$tr('singleSelectLabel'))[0]); + await fireEvent.click(screen.getByText(tr.$tr('inlineChoiceLabel'))); + await nextTick(); + + const switched = emitted()['wrapper-update'].at(-1)[0]; + expect(switched.bodyXml).toContain('Which planet is closest to the Sun?'); + expect(switched.bodyXml).toContain(' { it('renders nothing editable', () => { renderSection({ interaction: interactionBlock(UNKNOWN_INTERACTION_XML), mode: 'edit' }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js index dbc33766d4..bb44b3e88d 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js @@ -44,6 +44,8 @@ const { hintsLabel$, associateLabel$, matchLabel$, + inlineChoiceLabel$, + passageEditorLabel$, questionNumberAndTypeLabel$, unknownTypeLabel$, responsePoolLabel$, @@ -245,7 +247,6 @@ describe('QTIItemEditor', () => { const publishableDocuments = { 'two interactions': MULTI_INTERACTION_ITEM_DOCUMENT, 'an interaction with no descriptor': UNRECOGNIZED_INTERACTION_ITEM_DOCUMENT, - 'an interaction with no editor': INLINE_CHOICE_ITEM_DOCUMENT, 'several blanks in one text entry': MULTI_TEXT_ENTRY_ITEM_DOCUMENT, 'a stimulus beside a block interaction': CHOICE_ITEM_DOCUMENT_WITH_STIMULUS, 'a stimulus beside a hinted block interaction': @@ -578,6 +579,41 @@ describe('QTIItemEditor', () => { }); }); + describe('inline choice interaction', () => { + const renderInlineChoiceItem = (props = {}) => + renderComponent({ + item: { + assessment_id: 'test-item-id', + type: AssessmentItemTypes.QTI, + raw_data: INLINE_CHOICE_ITEM_DOCUMENT, + }, + ...props, + }); + + test('names the inline choice question type', async () => { + renderInlineChoiceItem(); + const heading = questionNumberAndTypeLabel$({ + number: 1, + total: 5, + type: inlineChoiceLabel$(), + }); + expect(await screen.findByRole('heading', { name: heading })).toBeInTheDocument(); + }); + + test('opens in the inline choice editor', async () => { + renderInlineChoiceItem({ mode: 'edit' }); + expect( + await screen.findByRole('heading', { name: passageEditorLabel$() }), + ).toBeInTheDocument(); + expect(screen.queryByTestId('unsupportedMessage')).not.toBeInTheDocument(); + }); + + test('marks a dropdown without a correct answer as incomplete', async () => { + renderInlineChoiceItem(); + expect(await screen.findByTestId('incompleteIndicator')).toBeInTheDocument(); + }); + }); + describe('toolbarActions slot', () => { test('renders content injected into the toolbarActions slot', () => { renderComponent({}, { toolbarActions: '' }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue index db003f725d..7447241c89 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue @@ -213,6 +213,7 @@ [QuestionType.ORDERING]: qtiEditorStrings.orderingLabel$, [QuestionType.ASSOCIATE]: qtiEditorStrings.associateLabel$, [QuestionType.MATCH]: qtiEditorStrings.matchLabel$, + [QuestionType.INLINE_CHOICE]: qtiEditorStrings.inlineChoiceLabel$, }; return (QUESTION_TYPE_LABELS[type] ?? unknownTypeLabel$)(); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInlineChoiceInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInlineChoiceInteraction.spec.js new file mode 100644 index 0000000000..1987f13648 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInlineChoiceInteraction.spec.js @@ -0,0 +1,67 @@ +import { nextTick, ref } from 'vue'; +import { useInlineChoiceInteraction } from '../useInlineChoiceInteraction'; +import { inlineChoiceInteractionDescriptor } from '../../interactions/inlineChoice/Descriptor'; +import { QuestionType, ValidationError } from '../../constants'; +import { chip } from '../../interactions/inlineChoice/__tests__/renderPassage'; + +const PASSAGE = `

The ${chip('r1', [ + ['a', 'Moon'], + ['b', 'Sun'], +])} rises

`; + +function setup(state = { prompt: '', passage: PASSAGE, shuffle: false }) { + const questionType = ref(QuestionType.INLINE_CHOICE); + const block = inlineChoiceInteractionDescriptor.buildXML(state, QuestionType.INLINE_CHOICE); + return useInlineChoiceInteraction(block, questionType); +} + +describe('useInlineChoiceInteraction', () => { + it('reads the dropdowns out of the passage', () => { + const { dropdowns } = setup(); + expect(dropdowns.value).toEqual([ + { + responseIdentifier: 'r1', + options: [ + { id: 'a', text: 'Moon' }, + { id: 'b', text: 'Sun' }, + ], + correctId: null, + }, + ]); + }); + + it('sets the question', () => { + const { state, setPrompt } = setup(); + setPrompt('

Fill in the blanks

'); + expect(state.value.prompt).toBe('

Fill in the blanks

'); + }); + + it('sets the passage, and the dropdowns follow it', () => { + const { dropdowns, setPassage } = setup(); + setPassage(`

${chip('r2', [['c', 'Earth']], 'c')}

`); + expect(dropdowns.value.map(d => d.responseIdentifier)).toEqual(['r2']); + }); + + it('writes Shuffle onto every dropdown in the saved XML', () => { + const { bodyXml, setShuffle } = setup({ + prompt: '', + passage: `

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

`, + shuffle: false, + }); + setShuffle(true); + const shuffles = [...bodyXml.value.matchAll(/shuffle="(\w+)"/g)].map(m => m[1]); + expect(shuffles).toEqual(['true', 'true']); + }); + + it('reads Shuffle back from a saved item', () => { + const { state } = setup({ prompt: '', passage: PASSAGE, shuffle: true }); + expect(state.value.shuffle).toBe(true); + }); + + it('reports a passage with no dropdown', async () => { + const { errors, setPassage } = setup(); + setPassage('

No dropdowns here

'); + await nextTick(); + expect(errors.value).toEqual([{ code: ValidationError.NO_INTERACTION }]); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInlineChoiceInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInlineChoiceInteraction.js new file mode 100644 index 0000000000..28364e13c5 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInlineChoiceInteraction.js @@ -0,0 +1,43 @@ +import { computed, readonly } from 'vue'; +import { inlineChoiceInteractionDescriptor } from '../interactions/inlineChoice/Descriptor'; +import { getDropdowns } from '../interactions/inlineChoice/parse'; +import { useInteraction } from './useInteraction'; + +/** + * Composable for the inline choice interaction editor. + * + * Extends useInteraction with the question-level mutations. A dropdown's options are not + * edited here: they live in the passage, and are changed through the passage editor's node + * commands so that each edit is one step of its history. + * + * @param {{ bodyXml: string, responseDeclarations: string[] }} interactionBlock + * @param {import('vue').Ref} questionType + */ +export function useInlineChoiceInteraction(interactionBlock, questionType) { + const base = useInteraction(inlineChoiceInteractionDescriptor, interactionBlock, questionType); + const { state } = base; + + /** Every dropdown in the passage, in document order. */ + const dropdowns = computed(() => getDropdowns(state.value.passage)); + + function setPrompt(html) { + state.value = { ...state.value, prompt: html }; + } + + function setPassage(html) { + state.value = { ...state.value, passage: html }; + } + + function setShuffle(val) { + state.value = { ...state.value, shuffle: val }; + } + + return { + ...base, + state: readonly(state), + dropdowns, + setPrompt, + setPassage, + setShuffle, + }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/resolveDescriptor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/resolveDescriptor.spec.js index 517512891f..50f7ef5855 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/resolveDescriptor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/resolveDescriptor.spec.js @@ -89,7 +89,6 @@ describe('isSupportedItem', () => { ['two choice interactions', interactionsOf(MULTI_INTERACTION_ITEM_DOCUMENT)], ['a choice and a text entry', interactionsOf(TWO_INTERACTIONS_DOCUMENT)], ['an interaction with no descriptor', interactionsOf(UNRECOGNIZED_INTERACTION_ITEM_DOCUMENT)], - ['an interaction with no editor', interactionsOf(INLINE_CHOICE_ITEM_DOCUMENT)], ['several text entries in one body', interactionsOf(MULTI_TEXT_ENTRY_ITEM_DOCUMENT)], [ 'an interaction whose question type cannot be read', @@ -103,6 +102,14 @@ describe('isSupportedItem', () => { ['one choice interaction', VALID_CHOICE_ITEM_DOCUMENT], ['a match interaction', VALID_MATCH_ITEM_DOCUMENT], ['a text entry after its prompt', FREE_RESPONSE_ITEM_DOCUMENT], + ['inline choice dropdowns in a passage', INLINE_CHOICE_ITEM_DOCUMENT], + [ + 'inline choice dropdowns after a question, across paragraphs', + INLINE_CHOICE_ITEM_DOCUMENT.replace( + '', + '

Fill in the blanks

Read this first.

', + ), + ], ])('accepts %s', (_, document) => { expect(isSupported(document)).toBe(true); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js index 395196005d..45ffdcbb10 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js @@ -35,9 +35,9 @@ export const registry = Object.fromEntries(descriptors.map(d => [d.type, d])); /** * Interactions with a descriptor but no editor in index.js yet. Items holding one are - * shown read-only. TODO: #6182 removes inline choice. + * shown read-only. */ -export const HEADLESS_INTERACTIONS = Object.freeze([QtiInteraction.INLINE_CHOICE]); +export const HEADLESS_INTERACTIONS = Object.freeze([]); /** * Inline interactions whose editor holds one per item body. A body with several would diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js index 312242e5ea..29658a23ce 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js @@ -4,6 +4,7 @@ import TextEntryEditor from './textEntry/Editor.vue'; import OrderingEditor from './ordering/Editor.vue'; import AssociateEditor from './associate/Editor.vue'; import MatchEditor from './match/Editor.vue'; +import InlineChoiceEditor from './inlineChoice/Editor.vue'; /** * Entry point for the editor tree: the descriptors, plus the Vue component that edits each @@ -19,6 +20,7 @@ export const editors = Object.freeze({ [QtiInteraction.ORDER]: OrderingEditor, [QtiInteraction.ASSOCIATE]: AssociateEditor, [QtiInteraction.MATCH]: MatchEditor, + [QtiInteraction.INLINE_CHOICE]: InlineChoiceEditor, }); export { descriptors, registry } from './descriptors'; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/Descriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/Descriptor.js index fba7e53f64..75fa995cab 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/Descriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/Descriptor.js @@ -18,11 +18,32 @@ class InlineChoiceInteractionDescriptor extends InteractionDescriptor { }); } + getTypeOptions(tr) { + return [ + { + value: QuestionType.INLINE_CHOICE, + label: tr.inlineChoiceLabel$(), + description: tr.inlineChoiceDescription$(), + }, + ]; + } + matches(el) { if (el.tagName.toLowerCase() === QtiInteraction.INLINE_CHOICE) return true; return Boolean(el.querySelector(QtiInteraction.INLINE_CHOICE)); } + /** + * parse() reads every body: elements it wrote as the question go back to the question, and + * everything else is the passage. Another interaction in the body would be its own block, + * which isSupportedItem already rejects. + * + * @returns {boolean} + */ + isSupportedBody() { + return true; + } + getQuestionType() { return QuestionType.INLINE_CHOICE; } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/Editor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/Editor.vue new file mode 100644 index 0000000000..9e07e4cbc7 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/Editor.vue @@ -0,0 +1,447 @@ + + + + + + + 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 dff171dba1..e536315892 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/InlineChoiceNode.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/InlineChoiceNode.js @@ -2,7 +2,7 @@ /* eslint-disable import/no-unresolved */ import { isHistoryTransaction } from '@tiptap/pm/history'; import { Fragment, Slice } from '@tiptap/pm/model'; -import { NodeSelection, Plugin } from '@tiptap/pm/state'; +import { Plugin } from '@tiptap/pm/state'; /* eslint-enable import/no-unresolved */ import { Node, @@ -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( @@ -70,6 +70,24 @@ function trimmedRange({ doc, selection }) { return { from, to }; } +/** + * Backspace and Delete next to a chip remove it, as one step of history. ProseMirror leaves + * these keys to the browser beside an inline node that cannot be selected, and browsers differ + * on what they do beside an uneditable element. + */ +function deleteChip(view, event) { + const { state } = view; + const { selection } = state; + if (!selection.empty || event.altKey || event.ctrlKey || event.metaKey) return false; + const { $head } = selection; + const backward = event.key === 'Backspace'; + const next = backward ? $head.nodeBefore : $head.nodeAfter; + if (!next || !isChip(next)) return false; + const from = backward ? $head.pos - next.nodeSize : $head.pos; + view.dispatch(state.tr.delete(from, from + next.nodeSize).scrollIntoView()); + return true; +} + function reidentify(fragment, taken) { const children = []; fragment.forEach(child => { @@ -91,6 +109,9 @@ export const InlineChoiceNode = Node.create({ group: 'inline', inline: true, atom: true, + // The caret moves past a chip rather than selecting it: a chip is not a place to type, and its + // own button is how the keyboard reaches its options. + selectable: false, addAttributes() { return { @@ -157,12 +178,8 @@ export const InlineChoiceNode = Node.create({ } if (correctId !== undefined) attrs.correctId = correctId; if (!attrs.options.some(option => option.id === attrs.correctId)) attrs.correctId = null; - if (dispatch) { - const wasSelected = tr.selection.node && tr.selection.from === found.pos; - // Replaces the chip, so history groups quick successive edits as it does typing. - tr.setNodeMarkup(found.pos, undefined, attrs); - if (wasSelected) tr.setSelection(NodeSelection.create(tr.doc, found.pos)); - } + // Replaces the chip, so history groups quick successive edits as it does typing. + if (dispatch) tr.setNodeMarkup(found.pos, undefined, attrs); return true; }, }; @@ -176,6 +193,9 @@ export const InlineChoiceNode = Node.create({ return [ new Plugin({ props: { + handleKeyDown(view, event) { + return ['Backspace', 'Delete'].includes(event.key) && deleteChip(view, event); + }, // A copy must not share ids with the original; a cut-paste has already removed them. transformPasted(slice, view) { // Whether a drag moves or copies is only known at drop; see `appendTransaction`. @@ -186,8 +206,9 @@ export const InlineChoiceNode = Node.create({ return new Slice(reidentify(slice.content, taken), slice.openStart, slice.openEnd); }, }, - // Catches inserts `transformPasted` does not see, such as a drop. An inserted chip whose - // ids are still in use elsewhere was copied, not moved. + // Catches inserts `transformPasted` does not see, such as a drop, or the toolbar's Paste, + // which inserts through `insertContent`. An inserted chip whose ids are still in use + // elsewhere was copied, not moved. appendTransaction(transactions, oldState, newState) { // Undo and redo only restore states this plugin already accepted. if (transactions.every(isHistoryTransaction)) return null; 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..85696ec9fa --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/Editor.spec.js @@ -0,0 +1,844 @@ +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(), sendAssertiveMessage: 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('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: { + 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']); + // 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']); + }); + + 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/); + // 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(); + }); + + 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 }), + ); + }); + + 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 5ddfe7bdda..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 @@ -1,6 +1,9 @@ import { nextTick, ref } from 'vue'; import { cleanup, fireEvent, screen, waitFor } from '@testing-library/vue'; import userEvent from '@testing-library/user-event'; +// The resolver does not read package `exports` subpaths. +// eslint-disable-next-line import/no-unresolved +import { NodeSelection } from '@tiptap/pm/state'; import { qtiEditorStrings } from '../../../qtiEditorStrings'; import { CHIP, chip, findChip, renderPassage } from './renderPassage'; import { stubProseMirrorLayout } from 'shared/utils/testing'; @@ -122,39 +125,166 @@ describe('InlineChoiceChip', () => { fireEvent.keyDown(editor.view.dom, { key, keyCode, ...init }); it.each([ - ['ArrowRight', 39, 2], - ['ArrowLeft', 37, 3], - ])('does not open a chip reached with %s', async (key, keyCode, cursor) => { - const { editor, openResponseIdentifier } = await renderPassage({ + ['Backspace', 8, 3], + ['Delete', 46, 2], + ])('removes the chip beside the caret with %s', async (key, keyCode, cursor) => { + const { editor } = await renderPassage({ value: `

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

`, }); editor.commands.setTextSelection(cursor); press(editor, key, keyCode); - expect(editor.state.selection.node).toBeDefined(); - await nextTick(); - expect(openResponseIdentifier.value).toBeNull(); - expect(getChip()).toHaveAttribute('aria-expanded', 'false'); + expect(editor.getHTML()).toBe('

ab

'); + }); + + // ProseMirror moves the caret past an inline node it cannot select, rather than onto it. + it('cannot be selected on its own, so the caret moves past it', async () => { + const { editor } = await renderPassage({ + value: `

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

`, + }); + expect(NodeSelection.isSelectable(findChip(editor, 'r1').node)).toBe(false); + + editor.commands.setTextSelection(2); + press(editor, 'ArrowRight', 39); + expect(editor.state.selection.empty).toBe(true); + expect(editor.state.selection.head).toBe(3); }); it.each([ - ['Enter', 13], - [' ', 32], - ])('does not open a chip the caret is on when %p is pressed', async (key, keyCode) => { + ['{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

`, }); - editor.commands.setNodeSelection(chipPos(editor, 'r1')); - press(editor, key, keyCode); - await nextTick(); + 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('deletes a keyboard-selected chip with Backspace and restores it with undo', async () => { + 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

`, }); - editor.commands.setTextSelection(2); - press(editor, 'ArrowRight', 39); + 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')}

`, + }); + editor.commands.setTextSelection({ from: 1, to: 4 }); + const [first, second] = getChips(); + await waitFor(() => expect(first).toHaveClass('is-selected')); + expect(second).not.toHaveClass('is-selected'); + + editor.commands.setTextSelection(1); + await waitFor(() => expect(first).not.toHaveClass('is-selected')); + }); + + it('deletes a chip with Backspace and restores it with undo', async () => { + const { editor } = await renderPassage({ + value: `

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

`, + }); + editor.commands.setTextSelection(3); press(editor, 'Backspace', 8); await waitFor(() => expect(screen.queryByRole('button', CHIP)).not.toBeInTheDocument()); press(editor, 'z', 90, { ctrlKey: true }); @@ -180,20 +310,9 @@ describe('InlineChoiceChip', () => { expect(() => focusChip('gone')).not.toThrow(); }); - it('outlines a node-selected chip without opening it', async () => { - const { editor, openResponseIdentifier } = await renderPassage({ - value: `

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

`, - }); - editor.commands.setNodeSelection(chipPos(editor, 'r1')); - await waitFor(() => expect(getChip()).toHaveClass('is-selected')); - expect(openResponseIdentifier.value).toBeNull(); - - editor.commands.setTextSelection(1); - await waitFor(() => expect(getChip()).not.toHaveClass('is-selected')); - }); - // The open chip's panel (#6182) sits outside the editor; using it must not close the editor. - // Relies on `useClickOutside`'s `hasOpenMenu` matching the chip's `aria-expanded` button. + // The editor stays open while a trigger inside it has an expanded popup; see "Popups outside + // the editor" in docs/rich_text_editor.md. it('keeps the editor open on a click outside it while a chip is open', async () => { const { onMinimize } = await renderPassage({ value: `

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

`, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/InlineChoiceNode.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/InlineChoiceNode.spec.js index 202a5b6911..5d756e604a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/InlineChoiceNode.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/InlineChoiceNode.spec.js @@ -128,14 +128,6 @@ describe('InlineChoiceNode', () => { expect(findChip(editor, 'r1').node.attrs.correctId).toBeNull(); }); - it('keeps a selected chip selected', async () => { - const { editor } = await renderPassage({ value: `

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

` }); - const { pos } = findChip(editor, 'r1'); - editor.commands.setNodeSelection(pos); - editor.commands.updateInlineChoice('r1', { options: [{ id: 'c1', text: 'y' }] }); - expect(editor.state.selection.toJSON()).toEqual({ type: 'node', anchor: pos }); - }); - it('gives an added option with no id a fresh one, keeping the other ids', async () => { const { editor } = await renderPassage({ value }); editor.commands.updateInlineChoice('r1', { 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..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 @@ -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, @@ -71,6 +71,8 @@ export async function renderPassage({ value, errorResponseIdentifiers, mode = 'e 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 df1e61fbdc..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 { screen, waitFor } from '@testing-library/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'; @@ -8,6 +9,23 @@ 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) }), + }; +}); + +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()); @@ -19,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(); @@ -151,20 +169,6 @@ describe('useInlineChoicePassage Insert action', () => { expect(dropdowns(editor)).toHaveLength(1); }); - it('adds a new chip after a node-selected chip instead of replacing it', async () => { - const { editor, openResponseIdentifier } = await renderAt(`

a${chipHTML(ORIGINAL)}b

`, 1); - editor.commands.setNodeSelection(2); - await nextTick(); - await clickInsert(); - - expect(dropdowns(editor)).toHaveLength(2); - const [first, second] = dropdowns(editor); - expect(first).toEqual(ORIGINAL); - expect(second.options).toEqual([{ id: expect.any(String), text: '' }]); - expect(editor.getHTML()).toBe(`

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

`); - expect(openResponseIdentifier.value).toBe(second.responseIdentifier); - }); - it('keeps a chip inside the highlighted text and adds the new one after it', async () => { const { editor } = await renderAt(`

a ${chipHTML(ORIGINAL)} b

`, 1, 5); await clickInsert(); @@ -176,3 +180,146 @@ describe('useInlineChoicePassage Insert action', () => { expect(editor.getHTML()).toBe(`

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

`); }); }); + +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(); + + 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..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 @@ -11,19 +11,36 @@ '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 }), ]" data-copy-ignore @click="isEditable && open()" + @keydown="isEditable && onKeydown($event)" > - + + + {{ $formatNumber(optionCount) }} {{ label }} @@ -46,8 +63,8 @@ // eslint-disable-next-line import/no-unresolved import { DOMSerializer } from '@tiptap/pm/model'; import { NodeViewWrapper } from '@tiptap/vue-2'; - import { qtiEditorStrings } from '../../../../qtiEditorStrings'; - import { injectPassageChips } from '../../passageChips'; + import { describeChip, injectPassageChips } from '../../passageChips'; + import { historyShortcut } from '../../historyShortcut'; export default { name: 'InlineChoiceChip', @@ -57,42 +74,31 @@ }, setup(props) { - const { openResponseIdentifier, openDropdown, isEditable, errorResponseIdentifiers } = - injectPassageChips(); const { - addAnswers$, - answerDropdownWithCorrect$, - answerDropdownNoCorrect$, - answerDropdownWithCorrectNeedsAttention$, - answerDropdownNoCorrectNeedsAttention$, - } = qtiEditorStrings; - + openResponseIdentifier, + openDropdown, + focusChip, + stepHistory: stepPassageHistory, + isEditable, + errorResponseIdentifiers, + showAnswers, + } = injectPassageChips(); const responseIdentifier = computed(() => 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), ); - const label = computed(() => (hasAnswer.value ? correctText.value : addAnswers$())); - // Starts with the visible label, so voice control finds the chip by what it shows. - const accessibleName = computed(() => { - const name = hasAnswer.value - ? hasErrors.value - ? answerDropdownWithCorrectNeedsAttention$ - : answerDropdownWithCorrect$ - : hasErrors.value - ? answerDropdownNoCorrectNeedsAttention$ - : answerDropdownNoCorrect$; - return name({ label: label.value, count: optionCount.value }); - }); + // A preview that hides the answers shows what the learner sees before choosing. + const isConcealed = computed(() => !isEditable.value && !showAnswers.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(() => @@ -111,15 +117,54 @@ { 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, hasAnswer, + isConcealed, hasErrors, label, accessibleName, isEditable, + showAnswers, isOpen, controlAttrs, }; @@ -138,6 +183,10 @@ type: Boolean, default: false, }, + getPos: { + type: Function, + required: true, + }, }, }; @@ -146,46 +195,53 @@ 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/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 7117a308ce..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,11 +1,14 @@ 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 } from './InlineChoiceNode'; -import { providePassageChips } from './passageChips'; +import { InlineChoiceNode, findChip, isChip } from './InlineChoiceNode'; +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 // paragraph, it is taken as that paragraph's text. @@ -16,20 +19,104 @@ 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); + 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); +} + +/** 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. + * + * `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 = [] } = {}) { +export function useInlineChoicePassage({ + errorResponseIdentifiers = [], + showAnswers = true, + describedBy = null, + 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,16 +126,90 @@ 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)); + } + + // 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, 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; + 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; @@ -58,9 +219,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 +257,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..673a2f73ef 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js @@ -545,4 +545,96 @@ 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: + '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/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 }, 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 = ` - + @@ -165,6 +169,8 @@