Skip to content

Add the inline choice interaction editor - #6303

Open
AlexVelezLl wants to merge 7 commits into
learningequality:unstablefrom
AlexVelezLl:issue-6182-inline-choice-editor
Open

AlexVelezLl wants to merge 7 commits into
learningequality:unstablefrom
AlexVelezLl:issue-6182-inline-choice-editor

Conversation

@AlexVelezLl

@AlexVelezLl AlexVelezLl commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Adds Inline choice to the question type selector, with its own editor.
  • Adds an options panel for the open dropdown, where authors add, edit, remove and reorder options and mark the correct one.
  • Makes the options panel a modal dialog that traps focus, returns it to the dropdown on Escape or close, and closes when the passage is clicked.
  • Adds a Shuffle setting, as the choice question has, and hides reordering while it's on.
  • Shows validation errors on the option, the dropdown and the passage they belong to.
  • Shows each correct answer in view mode when Show answers is on, and "Choose…" in its place when it's off.
  • Moves the caret past a dropdown instead of selecting it, and removes a dropdown with Backspace or Delete beside it.
  • Returns the caret to the text beside a focused dropdown on Left or Right.
  • Announces to screen readers a dropdown the caret crosses and dropdowns that are removed.
  • Describes the passage editor with its helper text, and names a dropdown that needs attention as such in a preview that hides the answers.
  • Shows an unavailable Insert like the toolbar's other disabled tools, and shrinks it to its icon in a small window.
  • Documents that an expanded popup keeps the rich text editor open.
  • Adds one helper for reading an editor's stored HTML, used by the editor and the passage.

References

Fixes #6182.

Beyond #6182's scope

#6182 leaves the dropdown chip and Insert to #6181, but this PR also changes the chip. Each change came up while building and testing the editor:

  • Makes a dropdown unselectable, so the caret moves past it, and removes it with Backspace or Delete beside it (InlineChoiceNode.js). The arrow keys stopped on each dropdown to select it before moving past, and Backspace selected it rather than removing it.
  • Returns the caret to the text from a focused dropdown on Left or Right (leaveForText). A dropdown is a focusable button in the text, and the arrow keys did nothing on it.
  • Announces dropdowns the caret crosses and dropdowns that are removed, through KDS's useKLiveRegion, with two new strings. A screen reader said nothing in either case, since a dropdown has no text to read out.
  • Restyles a dropdown covered by a text selection, following the design, since it no longer takes ProseMirror's node selection.

These can move to their own PR if preferred. Not done yet: undoing a removal brings the dropdown back without an announcement, while redo announces the removal.

Reviewer guidance

  • Seven commits, each a reviewable unit; the editor is the sixth.
  • Go do the exercise editor, add a new question, set the inline choice question type, and have fun with the new editor!
  • Try possible edge cases: copy/paste, do/redo, make sure IDs are not repeated, make sure keyboard navigation works smoothly, check that resulting XML is compatible with what Kolibri expects.

Demo

Grabacion.de.pantalla.2026-10-07.a.las.4.02.52.p.m.mov

AI usage

Claude Code implemented this from the issue and design screenshots, with tests, then self-reviewed it. I reviewed the changes and steered the design and accessibility decisions.

🤖 Generated with Claude Code

@AlexVelezLl
AlexVelezLl marked this pull request as ready for review October 7, 2026 21:08
@AlexVelezLl
AlexVelezLl requested review from a team, leq-bot and rtibblesbot October 7, 2026 21:08
@rtibblesbot

rtibblesbot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

🟡 Waiting for changes

Last updated: 2026-10-08 21:31 UTC

@rtibblesbot rtibblesbot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #6303: the inline choice editor works end to end in manual QA. The type selector, insert flows, options panel, Shuffle, copy/paste ids, save/reopen and view mode all behave as #6182 specifies. CI is passing.

QA ran in the qti-demo shell because the QA server returned Redis 500s. axe found no new violations.

Suggestions, all inline:

  • Options panel focus: clicking non-focusable parts drops focus, and aria-modal while the passage stays editable.
  • Validation goes stale after removing a chip as text.
  • Accessible name for the passage editor.
  • isSupportedBody accepts bodies TipTap can't hold.
  • Chip and announcer changes outside #6182's scope.
  • Toolbar CSS and narrow-width overflow of Insert.
  • Tests that match text anywhere on the page, and focus-trap tests that don't check where focus lands.

Written by rtibblesbot, an LLM-based coding agent.


<!-- Passage -->
<div class="inline-choice-editor__section">
<ValidationMessage v-if="hasDropdownErrors">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: This message blames the options, but it also shows for NO_CORRECT_ANSWER, a dropdown-level error. Reword it to cover both, e.g. "One or more inline choices need attention".

@AlexVelezLl
AlexVelezLl force-pushed the issue-6182-inline-choice-editor branch from 18fbfe8 to 6055318 Compare October 8, 2026 15:38

@rtibblesbot rtibblesbot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #6303: 6 prior findings are still open; please fix or reply on each thread. 3 new suggestions below. 7 of 14 prior findings are resolved and 1 acknowledged.

CI passes. Manual QA ran 12 scenarios on the qti-demo shell, since Redis MISCONF blocked Django login. Scenarios: insert, options panel, keyboard/screen reader, undo/paste, shuffle, save/reopen, validation, RTL/touch. No regressions.

Still open:

  • aria-modal while the passage stays clickable
  • isSupportedBody accepts bodies TipTap can't hold
  • chip behaviour that #6182 lists as out of scope
  • undo restoring a chip isn't announced
  • chip with options but no correct mark reads "Add answers"
  • Insert overflows the passage toolbar below ~480px without touch. QA: 413px wide in a 301px toolbar at 375px.
  • options use a bare <input>, not the #5927 plain-text row
  • options-problem message shown for NO_CORRECT_ANSWER (nitpick)
Prior-finding status

RESOLVED — InlineChoiceOptions/index.vue:9 — clicking heading/padding drops focus; Escape stops closing
UNADDRESSED — InlineChoiceOptions/index.vue:13 — aria-modal while passage stays interactive
RESOLVED — inlineChoice/Editor.vue:95 — validation stale after chip removed/restored
ACKNOWLEDGED — inlineChoice/Editor.vue:84 — passage editor indistinguishable from question field (separate issue under #6103)
UNADDRESSED — inlineChoice/Descriptor.js:43 — isSupportedBody accepts bodies TipTap can't hold
UNADDRESSED — InlineChoiceChip/index.vue:24 — chip behaviour out of scope per #6182
UNADDRESSED — InlineChoiceChip/index.vue:32 — chip with no correct mark reads as "Add answers"
UNADDRESSED — EditorToolbar.vue — Insert pushed off narrow toolbar (styling now via appearanceOverrides)
RESOLVED — inlineChoice/tests/Editor.spec.js — focus-trap tests pass with handlers swapped
RESOLVED — inlineChoice/tests/Editor.spec.js — no-id error test passes wherever it renders
RESOLVED — TipTapEditor/tests/imageSrc.spec.js — duplicates toStoredImageSrcs cases via stub
UNADDRESSED — InlineChoiceOptions/index.vue:97 — bare <input>, not the #5927 plain-text row
UNADDRESSED — inlineChoice/Editor.vue:52 — options-problem message for NO_CORRECT_ANSWER (nitpick)
RESOLVED — InlineChoiceChip/index.vue:14 — green fill on chip with no correct answer
RESOLVED — useInlineChoicePassage.js:75 — praise: live sync keeps editor history


Written by rtibblesbot, an LLM-based coding agent.

@AlexVelezLl
AlexVelezLl force-pushed the issue-6182-inline-choice-editor branch 3 times, most recently from 27faea8 to 79b7904 Compare October 8, 2026 18:29
AlexVelezLl and others added 7 commits October 8, 2026 13:41
An item with several responses gets no response processing while any of
them has nothing to score it by, such as an inline choice dropdown with
no correct answer yet. That is an unfinished item, which validation
already reports to the author, so it is not logged as well.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An unavailable action now fades as the toolbar's disabled buttons do,
with a not-allowed cursor, and no longer changes on hover or press as if
it could be used. In a small window it is an icon button, keeping its
name for assistive technology, since its full label ran past the
toolbar's edge in a narrow window or at a high zoom. As a last resort,
a toolbar still too wide for its editor scrolls.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The editor stays open on a click outside it while any trigger inside it
has aria-haspopup and aria-expanded="true". The toolbar's menus rely on
that, and so does a node view that opens a panel beside the editor, such
as the inline choice chip's options. It is now stated in the consumer
docs and tested for a popup opened from the content, so a change to the
check cannot silently close the editor while such a panel is in use.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A chip is not a place to type, and its button is how the keyboard
reaches its options, so it is no longer selectable as a node: the arrow
keys move past it rather than onto it. ProseMirror leaves Backspace and
Delete beside such a node to the browser, which handles them unevenly,
so they remove the chip themselves, in one undoable step.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The stored form of an editor's HTML, with images back to their stored
filenames, is now `storedHTML` in `utils/imageSrc.js`. `TipTapEditor`
reads its content out through it, and so can a consumer that reads an
editor directly, without repeating how that form is made.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Turns on the Inline choice question type: an optional question, the
passage holding the dropdown chips, and a modal options panel for the
open chip, trapping focus with KFocusTrap and returning it to the chip on
close. A press in the passage closes it, as the passage is then in use.
Option edits go through the chip node's commands, so each is one
step of the passage's history, and the passage is synced while a chip is
open so the panel always matches the saved state. A change to the chips
syncs it too, so validation follows a chip removed or restored without
waiting for the passage to lose focus. Shuffle is the choice
interaction's AnswerSettings, and hides reordering while on.

The chips follow the design, and take the selection colour as a whole
when a selection covers them. A preview without Show answers shows
"Choose…" in place of each answer. The descriptor accepts any item body,
since parse() splits every body into question and passage.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Left and Right on a focused chip put the caret back in the passage
  beside it, following the text direction, as they move past the chip
  there.
- A chip the caret crosses is announced by its accessible name, since a
  screen reader reads out the text the caret crosses and a chip has
  none. Removing chips is announced too. Both use KDS's assertive live
  region, so they are read as a crossed character would be.
- A chip that needs attention shows the error icon in place of its
  option count, as an option row does, so colour is not all that marks
  it. Its name says so in a preview that hides the answers too.
- The passage editor is described by its helper text.

The chip's label and accessible name move to passageChips.js, so the
chip and the announcements name it the same way.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@AlexVelezLl
AlexVelezLl force-pushed the issue-6182-inline-choice-editor branch from 79b7904 to 4ec7055 Compare October 8, 2026 18:44

@rtibblesbot rtibblesbot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #6303: manual QA found two blocking bugs:

  • Clicking another chip can miss when the page is scrolled to its end.
  • Ctrl+Z in an option field types back what it undid.

16 of 17 prior findings are resolved or acknowledged. Please address the open Editor.vue:56 nitpick: its message blames the options for a missing correct answer.

The other comments are smaller. CI pending. QA covered insert, options editing, keyboard, click-outside, chip navigation, undo/copy/paste, shuffle, save/reopen, validation, view mode and RTL. axe found nothing new.

Prior-finding status

RESOLVED — InlineChoiceOptions/index.vue:9 — clicking heading/padding drops focus to <body>
RESOLVED — InlineChoiceOptions/index.vue:13 — aria-modal while the passage stays usable
RESOLVED — inlineChoice/Editor.vue:100 — validation stale after chip removed/restored
ACKNOWLEDGED — inlineChoice/Editor.vue:89 — passage editor indistinguishable from question field
ACKNOWLEDGED — inlineChoice/Descriptor.js:43 — isSupportedBody accepts bodies TipTap can't hold
RESOLVED — InlineChoiceChip/index.vue:24 — chip behaviour changes out of scope
ACKNOWLEDGED — InlineChoiceChip/index.vue:42 — chip without correct mark reads like an option
RESOLVED — EditorToolbar.vue — Insert pushed off the toolbar in narrow windows
RESOLVED — inlineChoice/tests/Editor.spec.js — focus-trap tests pass with handlers swapped
RESOLVED — inlineChoice/tests/Editor.spec.js — no-id error test passes wherever it renders
RESOLVED — TipTapEditor/tests/imageSrc.spec.js — repeats toStoredImageSrcs cases through a stub
ACKNOWLEDGED — InlineChoiceOptions/index.vue:97 — bare <input> instead of plain-text toolbar row
UNADDRESSED — inlineChoice/Editor.vue:56 — message blames options for NO_CORRECT_ANSWER
RESOLVED — InlineChoiceChip/index.vue:14 — green fill on chip with no correct answer
RESOLVED — passageChips.js:45 — invalid chip marked by colour alone
RESOLVED — inlineChoice/Editor.vue:69 — helper text not referenced by the passage
RESOLVED — QTIItemEditor.spec.js:599 — findByText for the heading


Written by rtibblesbot, an LLM-based coding agent.

toolbar buttons take no focus, so a press, not a focus change, tells. -->
<div
class="inline-choice-editor__section"
@pointerdown.capture="closeOptions"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: Clicking another chip doesn't open it when the page is scrolled to its end.

Closing on pointerdown unmounts the panel before the click completes. The page gets shorter, so the browser clamps the scroll position. The chips then move down by the panel's height (~215px). Mouseup lands off the chip, so no click fires on it.

Steps: new Inline choice question (always added last) with three dropdowns, page scrolled to the bottom. Open dropdown 3, then click dropdown 1's chip.

  • Expected: panel switches to dropdown 1.
  • Actual: no panel opens. Once, the passage editor closed and focus fell to <body>.
  • Reproduced 3 times. With 1500px of bottom padding the same steps work, so the scroll clamp is the cause.

Close on click instead, or keep the panel's height reserved until the pointer is released.

})
"
dir="auto"
@input="setOptionText(option.id, $event.target.value)"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: Repeated Ctrl+Z in an option field types the undone text back.

Steps: insert a dropdown, type blue in Option 1, press Ctrl+Z six times.

  • Expected: blu, bl, b, empty, then earlier passage edits.
  • Actual: blu → bl → b → empty → b → bl. The chip follows each value.

Each native undo in the input fires @input. That records a new passage-history step. Once the input's own stack is empty, the browser hands Ctrl+Z to the passage editor. The passage editor then undoes those recorded undo steps.

Handle beforeinput here for historyUndo and historyRedo. Prevent the default, then run the passage editor's undo or redo, so only one history applies.

class="error-icon"
:color="$themeTokens.error"
/>
<span

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The error icon replaces the option count, so a new chip never shows its count while the author edits options.

A newly inserted chip has errors from the start. The issue says option edits update "the open chip's count and label immediately". My earlier passageChips.js:45 ask (don't mark an invalid chip by colour alone) still stands.

Render the KIcon before the badge instead of using v-if/v-else.


/* A last resort: the overflow list gives up tools first, but too narrow a window can
still leave what remains wider than the toolbar. */
overflow-x: auto;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The More menu (line 103) may now be clipped to the toolbar box.

overflow-x: auto also sets overflow-y to auto. That makes the toolbar the scroll parent for its popovers. KDS popovers stay inside their scroll parent by default.

Pass :constrainToScrollParent="false" to More, as FormatDropdown.vue:16 does. Then check More in a narrow window.

expect(announced()).toBe(qtiEditorStrings.answerDropdownWithCorrect$({ label: 'x', count: 1 }));
});

it('names a chip that needs attention as such', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The hasErrors branch of chipMessage has no test.

This test never passes errorResponseIdentifiers. Dropping { hasErrors } there passes every test.

Pass errorResponseIdentifiers: ['r2'] through renderAt and expect answerDropdownNoCorrectNeedsAttention$.

border-radius: 0.75em;
}

// A selected chip takes the selection's colour, which the browser only paints behind text,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: The selected chip doesn't take the selection's colour, though the comment here says it does.

QA saw a yellow chip inside blue selected text. The chip uses $themeBrand.secondary.v_100. The selected text uses the global ::selection (shared/styles/vuetify.scss:2482). So the chip looks like a separate highlight.

Match ::selection, or correct the comment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[QTI] Inline choice interaction editor

2 participants