You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
❌ This issue is not open for contribution. Visit Contributing guidelines to learn about the contributing process and how to find suitable issues.
Description
Complete the match interaction plugin end-to-end: XML parsing, XML assembly, validation, a useMatchInteraction composable built on useInteraction, and a working interactions/match/Editor.vue.
Along the way, extract the two pieces of the associate editor that both interactions need — the shuffled response pool and the distractor container — into shared components under shared/views/QTIEditor/components/. The distractor container is generalised into a reusable list of rich-text chips: the match editor uses one instance per matching row to hold that row's correct answers, and one instance for the distractor pool.
Complexity: High
Target branch:unstable
Context
The match interaction maps to <qti-match-interaction> and covers a "matching rows" question type: a set of prompts on one side, a shuffled set of responses on the other, and the learner drags responses onto the prompt they belong to.
In the UI it is very close to the associate interaction, with two differences:
There is no Pair N label — the editor shows raw matching rows.
A row is 1-N, not 1-1: each row has one prompt and a list of correct answers. Associate rows were always exactly two items.
Structurally, match differs from associate in that its choices live in two<qti-simple-match-set> elements rather than one flat pool, and the response declaration uses base-type="directedPair" rather than "pair" — "Row Col" and "Col Row" are not equivalent, the row identifier always comes first.
QTI XML reference
Official spec example (QTI 3.0 match interaction, IMS Global BPIG):
base-type is always "directedPair" (not "pair"). The row identifier is always the first token of a <qti-value>, the response identifier the second.
The interaction holds exactly two<qti-simple-match-set> elements, and their order is significant: the first is the row set (prompts), the second is the response set (answers + distractors). Emit them in that order and do not reorder the choices within a set — the authored order is what the editor shows on reload.
max-associations controls how many associations the learner may make in total. Always set it to the total number of <qti-value> entries in <qti-correct-response> (i.e. the sum of every row's answers).
match-max on a row choice is how many responses that row accepts. Emit match-max = max(row.matches.length, 1).
A response is either a correct answer or a distractor, never both. Distractors are exactly the response-set choices named by no <qti-correct-response> value. This is where match diverges from associate: associate shares one flat pool, so it reads a choice's surplus match-max capacity as distractor entries. Match has a dedicated response set, so appearance in the correct response is the only signal, and there is no surplus to read.
match-max on a response choice is how many rows it may be dropped onto. Emit the number of correct-response values naming it, or 1 for a distractor — never 0, which means unlimited in QTI rather than zero. It is never read on import: a hand-written item whose match-max disagrees with its correct response, or leaves it at the unlimited 0 the published spec example uses, still imports by appearances alone.
shuffle should always be emitted as "true".
A choice with no identifier is assigned generateRandomSlug('row') (row set) or generateRandomSlug('choice') (response set).
Identifiers must be unique across both match sets — resolve ids over the union of the two sets, not per set.
Two answers with identical content collapse into a single choice whose match-max counts both — that is how one response serves two rows. Distractors are never merged, with each other or with an answer: duplicated content on a distractor is a validation error the author has to see, and merging would make one of the two silently disappear instead. Row prompts are not deduplicated against responses either: they live in a different set and are different choices even when their text is identical.
State shape
Defined via JSDoc in interactions/match/parse.js:
/** * @typedef {object} MatchChoice * @property {string} id - QTI identifier, e.g. "choice_xlqTuVoq" * @property {string} content - HTML content of the <qti-simple-associable-choice> *//** * @typedef {object} MatchRow * @property {string} id - QTI identifier of the row's first-set choice * @property {string} content - HTML content of the row prompt * @property {MatchChoice[]} matches - Second-set choices this row is correctly matched to *//** * @typedef {object} MatchState * @property {string} responseIdentifier - Response identifier attribute * @property {string} prompt - HTML content of <qti-prompt>; default "" * @property {MatchRow[]} rows - Matching rows, in authored order * @property {MatchChoice[]} distractors - Response-set choices matched by no row */
Architecture this plugs into
Each interaction is a directory of Descriptor.js, Editor.vue, parse.js and validation.js, with its specs in __tests__/.
Descriptor.js exports a class extending interactions/InteractionDescriptor.js and a module singleton. The base class checks the contract in its constructor, i.e. at import time — there is no defineInteraction helper any more, and the editor component is not a property of the descriptor.
Descriptors must not import .vue files.descriptors.js is imported by the headless validator (validateItem.js), which runs outside any editor.
Registration is therefore in two places that have to agree: interactions/descriptors.js (the descriptors array) and interactions/index.js (the editors map keyed by QtiInteraction). interactions/__tests__/registry.spec.js asserts the two lists match.
DOM helpers (parseXML, getPromptHTML) live in serialization/xml.js.
Emptiness and duplicate comparison go through utils/richText.js — hasRichTextContent(content) and richTextComparisonKey(content). Do not strip tags by hand. A choice can be an image or a formula with no text at all, and a text-only test calls those empty and silently drops them.
useInteraction validates on every state change (the old 400 ms debounce is gone) and exposes runValidation.
An interaction editor emits update:interaction and update:errors. It is also handed a teleportTargetId prop, which only the choice editor uses.
The Change
Part A — extract the shared pieces from the associate editor
interactions/associate/Editor.vue currently owns both of these inline. Move them out and have the associate editor consume them, so it loses the corresponding template, script and style, and both interactions render the same markup from one source. Associate behaviour must not change — interactions/associate/__tests__/Editor.spec.js is the regression guard and should keep passing unmodified, except for the tests that move into the new components' specs.
A1. components/ShuffledResponsePool/index.vue
The read-only pool shown in mode="view" — a tinted box with a section label and a wrapping list of view-mode chips, green-bordered and green-tinted when the chip is a correct answer and answers are being revealed.
The component owns the shuffling; the caller passes the choices in state order.
The caller decides correctness, the component only paints it. Keep the existing $themePalette.green.v_600 border and green.v_50 background.
Keyed by position, not by id: a choice id may legitimately repeat in the pool.
A2. components/EditableChipList/index.vue
The generalised distractor container: a box holding N rich-text chips, each editable in place, plus a draft chip for the one being written. This is the component the match rows reuse for their correct answers. Its behaviour is carried over verbatim from the associate distractor pool; the one new capability is how a new chip is started.
props: {/** @type {Array<{ id: String, content: String }>} */chips: {type: Array,required: true},/** Index of the chip currently open for editing, or null. Controlled by the parent. */openIndex: {type: Number,default: null},/** The chip being written, or null. Controlled by the parent. */draft: {type: Object,default: null},/** 'button' renders an AddListItemButton; 'region' makes the whole box clickable. */addMode: {type: String,default: 'button'},addLabel: {type: String,required: true},// button label / region aria-labellistLabel: {type: String,required: true},// aria-label for the chip list/** (position: Number) => String — 1-based; accessible label for editing a chip */chipLabel: {type: Function,required: true},/** (position: Number) => String — 1-based; accessible label for deleting a chip */deleteLabel: {type: Function,required: true},/** Parallel to `chips`; null where a chip has no error */errorMessages: {type: Array,default: ()=>[]},}
emits: ['open-chip',// (index)'update-chip',// (index, html)'remove-chip',// (index)'open-draft','update-draft',// (html)'discard-draft','close',// TipTap 'minimize' — the parent commits and clears the open target]
addMode: 'button' reproduces today's associate distractor pool: an AddListItemButton under the chips.
addMode: 'region' makes the container itself the add affordance — cursor: text, clicking anywhere in the box that is not a chip opens the draft editor. This is what a matching row's answer list uses. It still needs a keyboard route to the same action, so use ClickableRegion — its hidden overlay button is already a focusable, labelled target — rather than a bare @click on a div.
The component is controlled: the parent passes openIndex and draft and decides what happens on close. That is deliberate — the QTI editors enforce at most one open TipTap editor per interaction, and the match editor has several chip lists plus a prompt and N row prompts competing for that single slot.
Resolve identifiers the way buildAssociateInteractionXML does.
Emit the row set first, then the response set, with match-max per rules 4 and 6.
Build the response set in two parts, per rule 10: the rows' answers grouped by content into one choice each, carrying the number of rows that answer serves as its match-max; then every distractor as its own choice with match-max="1", ungrouped.
Emit shuffle="true" and max-associations = total answers across all rows.
Build the <qti-response-declaration> with cardinality="multiple", base-type="directedPair" and one <qti-value> per answer, "rowId responseId", ordered by row and then by position within the row.
no row has both a non-blank prompt and ≥1 non-blank answer
TOO_FEW_ROWS(new)
Answers within a row cannot be the same
two answers in one row share a comparison key
DUPLICATE_MATCH_CONTENT(new)
Rows cannot repeat the same prompt
two rows share a prompt comparison key
DUPLICATE_ROW_CONTENT(new)
Distractors cannot repeat another item
a distractor's key equals another distractor's or any row's answer
DUPLICATE_DISTRACTOR_CONTENT
DUPLICATE_DISTRACTOR_CONTENT is what holds rule 5 up. Nothing in the state shape stops an author writing a distractor that reads the same as one of the answers, and the XML cannot represent that choice as both — so this is the rule that tells them, and it fires on the distractor rather than on the answer, because the answer is the one the question needs.
Two things that are not errors:
The same answer content matched by two different rows. That is the point of allowing match-max > 1, and it is how "which of these belong to X and to Y" is authored.
A row prompt whose text equals an answer's text. They live in different sets.
3. interactions/match/Descriptor.js
placement is left at its Placement.BLOCK default.
matches(el) is not overridden — the base class's tag-name comparison is correct for a block interaction.
getQuestionType() returns QuestionType.MATCH; match has exactly one question type.
Add EMPTY_ROW_CONTENT, ROW_WITHOUT_MATCH, TOO_FEW_ROWS, DUPLICATE_ROW_CONTENT and DUPLICATE_MATCH_CONTENT to ValidationError.
Add the new translation keys. matchLabel already exists as a placeholder — give it a matchDescription sibling and check its context still reads correctly.
Add [QuestionType.MATCH]: qtiEditorStrings.matchLabel$ to QUESTION_TYPE_LABELS in components/QTIItemEditor/index.vue. Registering the descriptor does not populate that map, and without the entry every match item's view-mode header reads "Unknown type".
6. composables/useMatchInteraction.js
Build on useInteraction, return readonly(state) plus mutation methods:
addRow() — appends a row with a blank prompt, one blank answer, and a generated id.
removeRow(index) — removes a row; refuses when only one row remains, the way removePair does.
setRowContent(index, html) — updates the row prompt.
addDistractor(content = ''), removeDistractor(index), setDistractorContent(index, html) — identical to the associate versions.
setPrompt(html)
Every method replaces state.value with a new object rather than mutating in place.
7. interactions/match/Editor.vue
A Vue SFC wiring the composable to the UI, in Composition API, KDS and theme tokens only. It should generally look similar to the associate interaction with these distinctions in mind:
Description
Design
Base state with matches and distractors.
On view mode, it should show prompt and correct answers in two columns
Mobile view
View mode renders the rows read-only in the same shape the associate editor uses for its pairs: the row prompt as a chip beside its answer chips (only shown if showAnswers), green when showAnswers. The only difference from associate is that the answers side can hold more than one chip, and that the "prompt" column is always displayed.
Editing rules (all inherited from the associate editor):
At most one TipTap editor is open across the whole interaction at any time.
Entering edit mode opens the question prompt when it is blank, otherwise the first row's prompt.
Deleting anything closes the open editor first, since open targets are held by index, then hands focus to the element taking the deleted one's place.
Leaving edit mode discards any draft rather than committing it — the parent has stopped listening, so a commit would go unreported.
Responsive: below the large breakpoint a row stacks its prompt above its answers, the way the associate pair row stacks its two cards; below the small breakpoint every row stacks whether or not it is being edited. Use useKResponsiveWindow, plain CSS in a <style> block, and no inline directional styles.
Acceptance Criteria
Shared components
ShuffledResponsePool and EditableChipList exist under shared/views/QTIEditor/components/, each with its own spec.
interactions/associate/Editor.vue consumes both and no longer carries its own copy of that template, script or CSS.
The associate editor's behaviour is unchanged: its Editor.spec.js passes without weakening any assertion. Tests only move when the behaviour they cover moved into a shared component's spec.
EditableChipList supports both add affordances, and addMode: 'region' is reachable by keyboard as well as by pointer.
Parsing and serialization
parseMatchInteraction reads both match sets, assigns each row its answers from the directedPair correct response in authored order, and takes every response named by no correct-response value — and only those — as a distractor, without reading match-max.
buildMatchInteractionXML round-trips: parse(buildXML(state)) produces an equivalent state, including a row with two answers, a response shared by two rows, and two distractors holding the same content.
The row set is always emitted before the response set, and every <qti-value> is "rowId responseId" in that order.
max-associations equals the total number of answers; shuffle="true" is emitted.
An interaction that does not hold exactly two <qti-simple-match-set> elements is treated as unsupported rather than opened in the editor, and malformed XML surfaces as a parse error rather than throwing.
Emptiness and duplicate detection go through utils/richText.js, so an image-only or formula-only choice is neither dropped nor reported blank.
Plugin wiring
The descriptor is registered in descriptors.js, the editor in index.js, and registry.spec.js passes.
descriptors.js and everything it imports stay free of .vue files — validateQtiItem validates a match item headlessly.
A match item's card header reads its question-type label, not "Unknown type".
useMatchInteraction adds and removes rows, answers and distractors safely, and refuses to remove the last row.
Editor
Rows show no Pair N numbering.
A row's answer container opens a draft editor when its empty area is clicked, shows cursor: text, and commits the draft when that editor closes with content.
update:errors is emitted so the question card can show that the item needs work.
Every user-visible string comes from createTranslator; no hard-coded copy.
Colours come from $themeTokens / $themePalette; no raw hex values; non-dynamic styles live in <style> blocks.
A11y: keyboard-only users can reach and operate every control — add/remove a row, add/remove an answer, add/remove a distractor, and open each editor — and focus lands somewhere sensible after each removal. No axe-core violations beyond the pre-existing AddListItemButton contrast one.
Existing lint and test suites pass; pre-commit run --all-files is clean.
Testing
Use the Editor.vue tests as an integration test of the entire editor, not only a unit test.
AGENTS.md and CLAUDE.md for the KDS / Composition API / i18n / testing conventions.
AI usage
I used Claude Code to draft this issue from the associate interaction implementation as merged in #6113, the QTI 3.0 match interaction spec example, and the abstraction plan for the shared pool and chip-list components. I reviewed the XML rules against the spec example and the existing parse.js / validation.js behaviour before including them.
❌ This issue is not open for contribution. Visit Contributing guidelines to learn about the contributing process and how to find suitable issues.
Description
Complete the match interaction plugin end-to-end: XML parsing, XML assembly, validation, a
useMatchInteractioncomposable built onuseInteraction, and a workinginteractions/match/Editor.vue.Along the way, extract the two pieces of the associate editor that both interactions need — the shuffled response pool and the distractor container — into shared components under
shared/views/QTIEditor/components/. The distractor container is generalised into a reusable list of rich-text chips: the match editor uses one instance per matching row to hold that row's correct answers, and one instance for the distractor pool.unstableContext
The match interaction maps to
<qti-match-interaction>and covers a "matching rows" question type: a set of prompts on one side, a shuffled set of responses on the other, and the learner drags responses onto the prompt they belong to.In the UI it is very close to the associate interaction, with two differences:
Pair Nlabel — the editor shows raw matching rows.Structurally, match differs from associate in that its choices live in two
<qti-simple-match-set>elements rather than one flat pool, and the response declaration usesbase-type="directedPair"rather than"pair"—"Row Col"and"Col Row"are not equivalent, the row identifier always comes first.QTI XML reference
Official spec example (QTI 3.0 match interaction, IMS Global BPIG):
Rules:
base-typeis always"directedPair"(not"pair"). The row identifier is always the first token of a<qti-value>, the response identifier the second.<qti-simple-match-set>elements, and their order is significant: the first is the row set (prompts), the second is the response set (answers + distractors). Emit them in that order and do not reorder the choices within a set — the authored order is what the editor shows on reload.max-associationscontrols how many associations the learner may make in total. Always set it to the total number of<qti-value>entries in<qti-correct-response>(i.e. the sum of every row's answers).match-maxon a row choice is how many responses that row accepts. Emitmatch-max = max(row.matches.length, 1).<qti-correct-response>value. This is where match diverges from associate: associate shares one flat pool, so it reads a choice's surplusmatch-maxcapacity as distractor entries. Match has a dedicated response set, so appearance in the correct response is the only signal, and there is no surplus to read.match-maxon a response choice is how many rows it may be dropped onto. Emit the number of correct-response values naming it, or1for a distractor — never0, which means unlimited in QTI rather than zero. It is never read on import: a hand-written item whosematch-maxdisagrees with its correct response, or leaves it at the unlimited0the published spec example uses, still imports by appearances alone.shuffleshould always be emitted as"true".identifieris assignedgenerateRandomSlug('row')(row set) orgenerateRandomSlug('choice')(response set).match-maxcounts both — that is how one response serves two rows. Distractors are never merged, with each other or with an answer: duplicated content on a distractor is a validation error the author has to see, and merging would make one of the two silently disappear instead. Row prompts are not deduplicated against responses either: they live in a different set and are different choices even when their text is identical.State shape
Defined via JSDoc in
interactions/match/parse.js:Architecture this plugs into
Descriptor.js,Editor.vue,parse.jsandvalidation.js, with its specs in__tests__/.Descriptor.jsexports a class extendinginteractions/InteractionDescriptor.jsand a module singleton. The base class checks the contract in its constructor, i.e. at import time — there is nodefineInteractionhelper any more, and the editor component is not a property of the descriptor..vuefiles.descriptors.jsis imported by the headless validator (validateItem.js), which runs outside any editor.interactions/descriptors.js(thedescriptorsarray) andinteractions/index.js(theeditorsmap keyed byQtiInteraction).interactions/__tests__/registry.spec.jsasserts the two lists match.parseXML,getPromptHTML) live inserialization/xml.js.utils/richText.js—hasRichTextContent(content)andrichTextComparisonKey(content). Do not strip tags by hand. A choice can be an image or a formula with no text at all, and a text-only test calls those empty and silently drops them.useInteractionvalidates on every state change (the old 400 ms debounce is gone) and exposesrunValidation.update:interactionandupdate:errors. It is also handed ateleportTargetIdprop, which only the choice editor uses.The Change
Part A — extract the shared pieces from the associate editor
interactions/associate/Editor.vuecurrently owns both of these inline. Move them out and have the associate editor consume them, so it loses the corresponding template, script and style, and both interactions render the same markup from one source. Associate behaviour must not change —interactions/associate/__tests__/Editor.spec.jsis the regression guard and should keep passing unmodified, except for the tests that move into the new components' specs.A1.
components/ShuffledResponsePool/index.vueThe read-only pool shown in
mode="view"— a tinted box with a section label and a wrapping list of view-mode chips, green-bordered and green-tinted when the chip is a correct answer and answers are being revealed.$themePalette.green.v_600border andgreen.v_50background.id: a choice id may legitimately repeat in the pool.A2.
components/EditableChipList/index.vueThe generalised distractor container: a box holding N rich-text chips, each editable in place, plus a draft chip for the one being written. This is the component the match rows reuse for their correct answers. Its behaviour is carried over verbatim from the associate distractor pool; the one new capability is how a new chip is started.
addMode: 'button'reproduces today's associate distractor pool: anAddListItemButtonunder the chips.addMode: 'region'makes the container itself the add affordance —cursor: text, clicking anywhere in the box that is not a chip opens the draft editor. This is what a matching row's answer list uses. It still needs a keyboard route to the same action, so useClickableRegion— its hidden overlay button is already a focusable, labelled target — rather than a bare@clickon adiv.The component is controlled: the parent passes
openIndexanddraftand decides what happens on close. That is deliberate — the QTI editors enforce at most one open TipTap editor per interaction, and the match editor has several chip lists plus a prompt and N row prompts competing for that single slot.Part B — the match interaction plugin
1.
interactions/match/parse.jsExport
parseMatchInteraction(bodyXml, responseDeclarations) → MatchState:<qti-simple-match-set>children. If there are fewer or more than two, the editor should not be able to open.rowsfrom the first set, in document order, each starting withmatches: [].QTIDeclaration.fromXML. Drop any value naming an identifier absent from the corresponding set.distractorsfrom the second set: every choice named by no correct-response value becomes one distractor, in document order.generateRandomSlug('row')/generateRandomSlug('choice')to choices with noidentifier._defaultState()seeds one row with a blank prompt and one blank answer, and no distractors — the same shape a freshly added row gets.Export
buildMatchInteractionXML(state, questionType, declarationSchema) → { bodyXml, responseDeclarations }:buildAssociateInteractionXMLdoes.match-maxper rules 4 and 6.match-max; then every distractor as its own choice withmatch-max="1", ungrouped.shuffle="true"andmax-associations= total answers across all rows.<qti-response-declaration>withcardinality="multiple",base-type="directedPair"and one<qti-value>per answer,"rowId responseId", ordered by row and then by position within the row.2.
interactions/match/validation.jsExport
validateMatchInteraction(state) → ValidationError[].state.promptis emptyPROMPT_REQUIREDcontentis emptyEMPTY_ROW_CONTENT(new)EMPTY_CHOICE_CONTENTROW_WITHOUT_MATCH(new)TOO_FEW_ROWS(new)DUPLICATE_MATCH_CONTENT(new)DUPLICATE_ROW_CONTENT(new)DUPLICATE_DISTRACTOR_CONTENTDUPLICATE_DISTRACTOR_CONTENTis what holds rule 5 up. Nothing in the state shape stops an author writing a distractor that reads the same as one of the answers, and the XML cannot represent that choice as both — so this is the rule that tells them, and it fires on the distractor rather than on the answer, because the answer is the one the question needs.Two things that are not errors:
match-max > 1, and it is how "which of these belong to X and to Y" is authored.3.
interactions/match/Descriptor.jsplacementis left at itsPlacement.BLOCKdefault.matches(el)is not overridden — the base class's tag-name comparison is correct for a block interaction.getQuestionType()returnsQuestionType.MATCH; match has exactly one question type.getResponseDeclarationSchema()returns{ baseType: BaseType.DIRECTED_PAIR, cardinality: Cardinality.MULTIPLE }.getTypeOptions(tr)returns the singlematchLabel$/matchDescription$option.parse,buildXMLandvalidatedelegate to the modules above..vueimport anywhere in this file or its imports.4. Registration
interactions/descriptors.js— import the singleton and append it todescriptors.interactions/index.js— import./match/Editor.vueand add it toeditorsunderQtiInteraction.MATCH.5.
constants.js,qtiEditorStrings.js&QTIItemEditorMATCH: 'match'toQuestionType.EMPTY_ROW_CONTENT,ROW_WITHOUT_MATCH,TOO_FEW_ROWS,DUPLICATE_ROW_CONTENTandDUPLICATE_MATCH_CONTENTtoValidationError.matchLabelalready exists as a placeholder — give it amatchDescriptionsibling and check itscontextstill reads correctly.[QuestionType.MATCH]: qtiEditorStrings.matchLabel$toQUESTION_TYPE_LABELSincomponents/QTIItemEditor/index.vue. Registering the descriptor does not populate that map, and without the entry every match item's view-mode header reads "Unknown type".6.
composables/useMatchInteraction.jsBuild on
useInteraction, returnreadonly(state)plus mutation methods:addRow()— appends a row with a blank prompt, one blank answer, and a generated id.removeRow(index)— removes a row; refuses when only one row remains, the wayremovePairdoes.setRowContent(index, html)— updates the row prompt.addMatch(rowIndex, content = ''),removeMatch(rowIndex, index),setMatchContent(rowIndex, index, html)addDistractor(content = ''),removeDistractor(index),setDistractorContent(index, html)— identical to the associate versions.setPrompt(html)Every method replaces
state.valuewith a new object rather than mutating in place.7.
interactions/match/Editor.vueA Vue SFC wiring the composable to the UI, in Composition API, KDS and theme tokens only. It should generally look similar to the associate interaction with these distinctions in mind:
View mode renders the rows read-only in the same shape the associate editor uses for its pairs: the row prompt as a chip beside its answer chips (only shown if
showAnswers), green whenshowAnswers. The only difference from associate is that the answers side can hold more than one chip, and that the "prompt" column is always displayed.Editing rules (all inherited from the associate editor):
Responsive: below the large breakpoint a row stacks its prompt above its answers, the way the associate pair row stacks its two cards; below the small breakpoint every row stacks whether or not it is being edited. Use
useKResponsiveWindow, plain CSS in a<style>block, and no inline directional styles.Acceptance Criteria
Shared components
ShuffledResponsePoolandEditableChipListexist undershared/views/QTIEditor/components/, each with its own spec.interactions/associate/Editor.vueconsumes both and no longer carries its own copy of that template, script or CSS.Editor.spec.jspasses without weakening any assertion. Tests only move when the behaviour they cover moved into a shared component's spec.EditableChipListsupports both add affordances, andaddMode: 'region'is reachable by keyboard as well as by pointer.Parsing and serialization
parseMatchInteractionreads both match sets, assigns each row its answers from thedirectedPaircorrect response in authored order, and takes every response named by no correct-response value — and only those — as a distractor, without readingmatch-max.buildMatchInteractionXMLround-trips:parse(buildXML(state))produces an equivalent state, including a row with two answers, a response shared by two rows, and two distractors holding the same content.<qti-value>is"rowId responseId"in that order.max-associationsequals the total number of answers;shuffle="true"is emitted.<qti-simple-match-set>elements is treated as unsupported rather than opened in the editor, and malformed XML surfaces as a parse error rather than throwing.utils/richText.js, so an image-only or formula-only choice is neither dropped nor reported blank.Plugin wiring
descriptors.js, the editor inindex.js, andregistry.spec.jspasses.descriptors.jsand everything it imports stay free of.vuefiles —validateQtiItemvalidates a match item headlessly.useMatchInteractionadds and removes rows, answers and distractors safely, and refuses to remove the last row.Editor
Pair Nnumbering.cursor: text, and commits the draft when that editor closes with content.update:errorsis emitted so the question card can show that the item needs work.createTranslator; no hard-coded copy.$themeTokens/$themePalette; no raw hex values; non-dynamic styles live in<style>blocks.AddListItemButtoncontrast one.pre-commit run --all-filesis clean.Testing
Use the
Editor.vuetests as an integration test of the entire editor, not only a unit test.References
shared/views/QTIEditor/interactions/associate/.AGENTS.mdandCLAUDE.mdfor the KDS / Composition API / i18n / testing conventions.AI usage
I used Claude Code to draft this issue from the associate interaction implementation as merged in #6113, the QTI 3.0 match interaction spec example, and the abstraction plan for the shared pool and chip-list components. I reviewed the XML rules against the spec example and the existing
parse.js/validation.jsbehaviour before including them.