Add QTI match interaction editor - #6168
rtibblesbot wants to merge 3 commits into
Conversation
26e289d to
26822e8
Compare
26822e8 to
94b8038
Compare
AlexVelezLl
left a comment
There was a problem hiding this comment.
Good initial implementation!
| ); | ||
|
|
||
| // Where focus goes once nothing nearer is left to take it. | ||
| function addControl() { |
There was a problem hiding this comment.
should this be something like getAddControl instead?
There was a problem hiding this comment.
Renamed to getAddControl in 8e0068c81. I searched every function the PR adds that returns a value: openMatchIndex, rowDraft, borderStyle and errorsWith in the match editor matched. The first two are gone with the draft refactor, and the others are now getBorderStyle / getErrorsWith.
| // An open chip's region is suppressed and stops no click, so a click into | ||
| // its editor reaches this box too — and must not start a draft. | ||
| function onRegionClick(event) { | ||
| if (event?.target?.closest('.chip-item')) return; | ||
| onAdd(); | ||
| } |
There was a problem hiding this comment.
Should we implement a click.stop within the ClickableRegion instead of this hard-coded check?
There was a problem hiding this comment.
The .closest check is gone (8e0068c81). The region is now suppressed while any of its editors is open, so a click inside an open chip never reaches an active region. I didn't put click.stop in ClickableRegion itself: a suppressed region that stopped clicks would swallow the document click TipTap closes on. The distractor box, which is always suppressed, would then stop closing other editors.
One case needed a stop anyway. With a native click, closing an editor re-renders before the click bubbles on, which unmounts the wrapper's @click.stop. Delete, Save and discard reopened a draft that way in the browser, so those three handlers now call stopPropagation() themselves.
| draftKey.value += 1; | ||
| emit('open-draft'); |
There was a problem hiding this comment.
Question: Is the content of the old draft reliably saved before remounting? Or is it rather an indirect, flaky behavior that may break under certain circumstances?
There was a problem hiding this comment.
Reliably, but through the blur. TipTap emits update on blur, and pressing add blurs the editor on mousedown (or on focus-out for the keyboard), so the content is committed before the click commits the draft. The list now keeps the draft's last-written content itself rather than reading a prop. In region mode the second press can't happen: the region is off while a draft is open.
There was a problem hiding this comment.
I don't think this is a good abstraction, let's try to use only the refs directly wherever we need them.
There was a problem hiding this comment.
Removed (8e0068c81). All 3 users now focus through refs after nextTick: EditableChipList, the associate pair rows and the match rows. Each focuses deleteButtons[min(index, last)], or the add control once nothing removable is left.
| // An empty list still needs a target to click. | ||
| &.is-region { | ||
| min-height: 64px; | ||
| cursor: text; | ||
|
|
||
| > ::v-deep .overlay-button { | ||
| cursor: text; | ||
| } | ||
| } |
There was a problem hiding this comment.
Could we create a new prop for this instead?
There was a problem hiding this comment.
Added ClickableRegion's textCursor prop: text cursor, no hover tint (8e0068c81). The v-deep on .overlay-button is gone.
| <EditableChipList | ||
| class="row-answers" | ||
| addMode="region" | ||
| :chips="row.matches" | ||
| :openIndex="openMatchIndex(index)" | ||
| :draft="rowDraft(index)" | ||
| :addLabel="addMatchLabel$({ number: index + 1 })" | ||
| :listLabel="rowAnswersLabel$({ number: index + 1 })" | ||
| :chipLabel="position => editMatchLabel$({ number: index + 1, position })" | ||
| :deleteLabel="position => deleteMatchBtn$({ number: index + 1, position })" | ||
| :errorMessages="matchErrorMessages[index]" | ||
| @open-chip="position => openMatch(index, position)" | ||
| @update-chip="(position, html) => setMatchContent(index, position, html)" | ||
| @remove-chip="position => onRemoveMatch(index, position)" | ||
| @open-draft="openDraft(index)" | ||
| @update-draft="setDraftContent" | ||
| @discard-draft="discardDraft" | ||
| @close="closeOpenTarget" | ||
| /> |
There was a problem hiding this comment.
For region mode, could we suspend the background color change on hover and only have the text cursor? Also, let's disable the clickable region if a draft is already open. Also, let's please add a save button for both modes, and only add a new chip when pressed. If the editor is closed and the save button was not pressed, let's add the content anyway. The save closes the open TipTapEditor.
There was a problem hiding this comment.
Done (8e0068c81, 22d646ff2):
- Region mode: text cursor, no hover tint.
- The region is off while any of its editors is open.
- The draft has a Save button in both modes. Save commits and closes, and closing without saving still commits.
| @discard-draft="discardDraft" | ||
| @close="closeOpenTarget" | ||
| /> | ||
|
|
There was a problem hiding this comment.
Also, let's not allow deleting the last answer; we should always have at least one. If only one is left, the remove button should be disabled.
There was a problem hiding this comment.
Done (22d646ff2). EditableChipList takes minChips, and answer lists pass 1: the remove buttons are disabled at the minimum, and a blank last answer is kept on close rather than dropped. removeMatch also refuses to remove a row's last answer.
| </div> | ||
| </ClickableRegion> | ||
|
|
||
| <EditableChipList |
There was a problem hiding this comment.
If a distractor is repeating an answer, the answer chip should also get flagged as an error, similar to how we do for the associate interaction.
There was a problem hiding this comment.
Done (22d646ff2): an answer whose content a distractor repeats shows "Distractors cannot repeat another item" with a red border.
|
|
||
| <EditableChipList | ||
| class="row-answers" | ||
| addMode="region" |
There was a problem hiding this comment.
If a chip answer is blank, let's have a placeholder text in it.
There was a problem hiding this comment.
Done (22d646ff2): a blank closed answer shows "Enter an answer" through the new placeholder prop on EditableChipList.
| <li | ||
| v-for="(choice, position) in row.matches" | ||
| :key="`${choice.id}-${position}`" | ||
| class="chip is-correct" |
There was a problem hiding this comment.
Let's not use the green background for correct answers listed in the answers section, only in the pool, if the associate. Just like the associate interaction does.
There was a problem hiding this comment.
Done (22d646ff2): the row answers use the neutral border, and green is left to the pool.
94b8038 to
ae35f88
Compare
EditableChipList adds a region add mode, reachable by keyboard through ClickableRegion's overlay button. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ae35f88 to
5859ac6
Compare




Summary
ShuffledResponsePoolandEditableChipList, used by both editors.References
Closes #6166. Builds on #6113.
Reviewer guidance
Open questions:
axe: only the existing
AddListItemButtoncontrast violation.QA steps
Setup:
pnpm devsetup,pnpm devserver, sign in as admin, open/channels/<channel-id>/#/qti-demoon an editable channel, click Question 8's (Match) pencil.Evidence
Match distractors
Authoring a new match item
match-author-from-empty.webm
Editing and deleting answers
match-edit-delete-answers.webm
Switching the open editor
s3-switch-open-editor.webm
Validation errors
s5-validation-trigger-clear.webm
Keyboard row deletion
match-keyboard-row-delete.webm
Leaving edit mode discards drafts
s7-leave-edit-discards-drafts.webm
View mode
Responsive layout
Associate regression
s9-assoc-add-edit-blank-delete.webm
AI usage
Used Claude Code to plan and implement the shared components, the match parser, validator and editor, following the associate plugin. Verified with the QTIEditor Jest suites, pre-commit, and headless-browser QA with keyboard walkthroughs and axe.
🤖 Generated with Claude Code
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
🟡 Waiting for feedback
Last updated: 2026-09-25 23:56 UTC