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
ae35f88 to
5859ac6
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>
5859ac6 to
202802b
Compare




Summary
ShuffledResponsePoolandEditableChipListfrom the associate editor; both editors use them.paddingprop toTipTapEditorand a publicfocus()toClickableRegion.References
Closes #6166. Builds on #6113.
Reviewer guidance
QA steps
Open
/channels/<channel-id>/#/qti-demo; Q7 is Associate, Q8 Match.unstable.Evidence
Switching editors
match-editor-switching.webm
Row answers
match-row-answers-placeholder-errors.webm
Duplicates
match-duplicate-errors-refresh-on-open.webm
Distinct distractors
match-distinct-distractors-no-errors.webm
Row removal
match-delete-middle-row-open-editor.webm
Associate distractors
associate-distractor-chips.webm
More captures (19)
AI usage
Claude Code planned and implemented this; verified with Jest, pre-commit, and browser QA with 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-27 02:13 UTC