Repository navigation
[QTI] Question Type Selector and Answer Settings #6033
Description
❌ This issue is not open for contribution. Visit Contributing guidelines to learn about the contributing process and how to find suitable issues.
Overview
Right now, the QTI editor has no way to switch question types (single choice / multiple choice) and no "Answer settings" controls. This issue adds both.
What we're building:
- Question type selector — a dropdown that lets the author pick the question type (Single choice / Multiple choice). Selecting a type rebuilds the interaction XML with the correct QTI cardinality.
- Answer settings section — two checkboxes that sit next to the type selector in the card header row:
- Shuffle answers for learners — already in state (
shuffle), just needs a UI control. - Show learners how many answers to select — when checked,
max-choicesandmin-choicesare set to the number of correct answers; when unchecked, both are left at0(unlimited).
- Shuffle answers for learners — already in state (
Both controls are owned by the individual interaction plugin (e.g. ChoiceInteractionEditor.vue) but need to appear in the card header area, which is rendered by QTIItemEditor. A Vue portal (teleport) is how we bridge that gap — the interaction component writes the controls into a named slot/target that the parent renders in the right place.
Complexity: Medium
Target branch: unstable
Context
The current component tree is:
QTIEditor (index.vue)
└── QTIItemEditor
├── header row ← where Type selector + Answer settings should eventually live
└── InteractionSection
└── ChoiceInteractionEditor ← interaction plugin lives here
QTIItemEditor renders the header (Question 1 of N) and delegates everything inside the card body to InteractionSection, which looks up the right editor component from the plugin registry and renders it.
The type selector and answer settings are interaction-specific — they only make sense for a choice interaction, not for a text-entry or matching interaction. So the code that drives them belongs inside ChoiceInteractionEditor, not in QTIItemEditor. A portal is the right mechanism to hoist that UI into the header row.
"Show learners how many answers to select" logic
- Checked (default):
max-choices = number of correct answers,min-choices = same. This tells learners exactly how many to pick. - Unchecked:
max-choices = 0,min-choices = 0. Learners see no count hint. - When the author changes which answers are correct,
max-choices/min-choicesmust update automatically (if the checkbox is checked). - For single choice this checkbox is hidden —
max-choicesis always1.
Identifier and title generation (related note)
assembleItem.js currently uses identifier || 'item' and title || '' as fallbacks. These will be properly generated in useQtiItem once the "add question" button is wired up with a type selector. There is already a TODO comment in the file marking this.
What changes
1. constants.js
Add a new AnswerSettings object (or extend ChoiceState) if needed. No new constants are strictly required — the feature is driven by a local ref in the composable.
2. useChoiceInteraction.js
Add a showAnswerCount ref (boolean, default true) and a setShowAnswerCount(val) mutation.
Add a computed effectiveMaxChoices:
- If
showAnswerCountisfalse→0. - If
showAnswerCountistrue→state.value.choices.filter(c => c.correct).length.
Pass effectiveMaxChoices into buildXML so it overrides state.maxChoices when assembling XML.
Use the existing pattern: mutate
state.valueas a new plain object so Vue's reactivity picks up the change.
3. ChoiceInteractionDescriptor.js / parse.js
buildXML already accepts state and derives maxChoices from it. No signature change needed — useChoiceInteraction just passes a modified state (or an override) when calling buildXML.
4. ChoiceInteractionEditor.vue
Add an Answer settings section rendered with the portal to the question settings. It contains:
- Shuffle answers for learners —
KCheckboxbound tosetShuffle. - Show learners how many answers to select —
KCheckboxbound tosetShowAnswerCount. Hidden whenquestionType === 'singleSelect'.
Each checkbox row has a KIconButton (info icon, KDS icon: 'help') next to it. Clicking it opens a KModal that explains what the setting does. The modal has a title, a short explanation, and a single Close button.
Use KCheckbox, KIconButton, and KModal from KDS. Do not use Vuetify.
5. qtiEditorStrings.js
Add translator strings:
answerSettingsLabel— "Answer settings"shuffleAnswersLabel— "Shuffle answers for learners"shuffleAnswersInfoTitle— "Shuffle answers for learners"shuffleAnswersInfoBody— "The order of answer choices will be randomized each time a learner sees this question. This helps prevent learners from memorizing answer positions rather than understanding the content."showAnswerCountLabel— "Show learners how many answers to select"showAnswerCountInfoTitle— "Answers to select"showAnswerCountInfoBody— "When enabled, learners see a hint below the answer options so they know how many answers to choose. Toggle this off to increase question difficulty."closeBtn— "Close"questionTypeInfoTitle— "Response type"singleChoiceDescription— "Learners choose one correct answer from a list of options."multipleChoiceDescription— "Learners identify all correct answers from a list, where more than one option may apply."
6. Question type selector
Add a KSelect in QTIItemEditor's header row that lists the available question types for the current interaction. For the choice interaction: Single choice, Multiple choice.
Changing the type emits update:questionType up to QTIItemEditor, which passes it back down as a prop to InteractionSection → ChoiceInteractionEditor.
The selector also has a KIconButton (info icon) next to it. Clicking it opens a KModal that describes all available question types — name + one-line description — so authors understand the differences before choosing. The modal lists only the types that the current interaction plugin supports (for choice: Single choice and Multiple choice).
Out of scope
- Any question type other than single choice and multiple choice.
min-choices/max-choicesnumber inputs (the checkbox is the only control).
Acceptance criteria
Answer settings
-
ChoiceInteractionEditorrenders an "Answer settings" section above the question prompt. - "Shuffle answers for learners" checkbox is bound to
state.shuffle; toggling it updates the emitted XML. - "Show learners how many answers to select" is hidden for single choice questions.
- When the checkbox is checked (default),
max-choicesandmin-choicesin the output XML equal the number of correct answers. - When the checkbox is unchecked,
max-choicesandmin-choicesare0in the output XML. - Changing which answers are correct while the checkbox is checked automatically updates
max-choices/min-choices. - Both checkboxes use
KCheckboxfrom KDS. No Vuetify. - Both checkboxes have accessible labels taken from
qtiEditorStrings. - Each checkbox row has a
KIconButton(info icon). Clicking it opens aKModalwith the setting's title and explanation text. - The modal has a single Close button that dismisses it.
- Modal title and body text come from
qtiEditorStrings(no hardcoded strings).
Question type selector
-
QTIItemEditorrenders a type selector in the card header. - Selector shows "Single choice" and "Multiple choice" for choice interactions.
- Changing the type updates
questionTypeand re-rendersChoiceInteractionEditorwith the new type. -
cardinalityin the response declaration XML updates to match the new type. - The selector uses
KSelectfrom KDS. - A
KIconButton(info icon) sits next to the selector. Clicking it opens aKModallisting each available question type with its name and a one-line description. - The type info modal has a Close button that dismisses it.
- All type names and descriptions come from
qtiEditorStrings(no hardcoded strings).
Testing
Use @testing-library/vue for all component tests.
useChoiceInteraction.spec.js
it('showAnswerCount defaults to true', () => {
const { showAnswerCount } = useChoiceInteraction(block, questionTypeRef);
expect(showAnswerCount.value).toBe(true);
});
it('setShowAnswerCount(false) sets max-choices to 0 in built XML', async () => {
const { setShowAnswerCount, bodyXml } = useChoiceInteraction(block, questionTypeRef);
setShowAnswerCount(false);
await nextTick();
const root = new DOMParser().parseFromString(bodyXml.value, 'text/xml').documentElement;
expect(root.getAttribute('max-choices')).toBe('0');
});
it('max-choices updates when correct answers change and showAnswerCount is true', async () => {
const { toggleCorrectChoice, bodyXml } = useChoiceInteraction(block, questionTypeRef);
// mark second choice correct (now 2 correct)
toggleCorrectChoice('choice_b');
await nextTick();
const root = new DOMParser().parseFromString(bodyXml.value, 'text/xml').documentElement;
expect(root.getAttribute('max-choices')).toBe('2');
});ChoiceInteractionEditor.spec.js
it('renders Answer settings section in edit mode', () => {
renderEditor({ interaction: block(CHOICE_MULTI_SELECT_XML), questionType: QuestionType.MULTI_SELECT });
expect(screen.getByText(tr.$tr('answerSettingsLabel'))).toBeInTheDocument();
});
it('hides show-answer-count checkbox for single choice', () => {
renderEditor({ interaction: block(CHOICE_SINGLE_SELECT_XML), questionType: QuestionType.SINGLE_SELECT });
expect(screen.queryByLabelText(tr.$tr('showAnswerCountLabel'))).not.toBeInTheDocument();
});
it('toggling shuffle emits updated XML with shuffle="true"', async () => {
const { emitted } = renderEditor({ interaction: block(CHOICE_MULTI_SELECT_XML), questionType: QuestionType.MULTI_SELECT });
await fireEvent.click(screen.getByLabelText(tr.$tr('shuffleAnswersLabel')));
const latest = emitted()['update:interaction'].at(-1)[0];
expect(latest.bodyXml).toContain('shuffle="true"');
});
it('clicking the info button next to shuffle opens a KModal', async () => {
renderEditor({ interaction: block(CHOICE_MULTI_SELECT_XML), questionType: QuestionType.MULTI_SELECT });
const infoBtn = screen.getByRole('button', { name: tr.$tr('shuffleAnswersInfoTitle') });
await fireEvent.click(infoBtn);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText(tr.$tr('shuffleAnswersInfoBody'))).toBeInTheDocument();
});
it('KModal closes when the Close button is clicked', async () => {
renderEditor({ interaction: block(CHOICE_MULTI_SELECT_XML), questionType: QuestionType.MULTI_SELECT });
const infoBtn = screen.getByRole('button', { name: tr.$tr('shuffleAnswersInfoTitle') });
await fireEvent.click(infoBtn);
await fireEvent.click(screen.getByRole('button', { name: tr.$tr('closeBtn') }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});QTIItemEditor.spec.js
it('clicking the type info button opens a modal with question type descriptions', async () => {
renderItemEditor({ item, index: 0, total: 1, mode: 'edit' });
const infoBtn = screen.getByRole('button', { name: tr.$tr('questionTypeInfoTitle') });
await fireEvent.click(infoBtn);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText(tr.$tr('singleChoiceDescription'))).toBeInTheDocument();
expect(screen.getByText(tr.$tr('multipleChoiceDescription'))).toBeInTheDocument();
});
it('type info modal closes on Close', async () => {
renderItemEditor({ item, index: 0, total: 1, mode: 'edit' });
await fireEvent.click(screen.getByRole('button', { name: tr.$tr('questionTypeInfoTitle') }));
await fireEvent.click(screen.getByRole('button', { name: tr.$tr('closeBtn') }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});Resources
- Based on discussions in implement choice interaction parsing and serialization for QTIEditor #6012 and slack thread.
Accessibility Requirements
- All
KCheckboxcontrols must have a visible label that is also their accessible name — no icon-only checkboxes. - The
KIconButtoninfo buttons must have anaria-labelmatching the modal title they open (e.g.aria-label="Shuffle answers for learners") so screen reader users know what will open. - Each
KModalmust trap focus while open and return focus to the triggering button when closed.KModalfrom KDS handles this automatically — do not suppress it. - The
KModalmust be dismissible with the Escape key. Again,KModalhandles this by default. - The
KSelecttype selector must have an accessible label (e.g."Response type") so it is not announced as an unlabelled combo box. - Do not use
aria-hiddenon any of the new controls. - All new strings must go through
qtiEditorStringsso they are translatable.
AI usage
I used Claude (Claude Code) to draft this issue from design decisions and the QTI editor architecture.

Activity
AlexVelezLl commentedon Jul 16, 2026
Thanks @Abhishek-Punhani! Just tweaked a bit the issue description to include the teleport given that we will be building both the type selector and the answer settings in the same PR, so we can just implement the teleport there :). Feel free to take on this after #6012 is merged (which will happen pretty soon)