Choice Interaction
Choice interactions model single-choice or multiple-choice questions with a prompt and a list of selectable options.
Install
pnpm add @citolab/prose-qtiThe choice interaction is exposed as a subpath export from @citolab/prose-qti, so you import it from @citolab/prose-qti/components/choice and register the custom elements with @citolab/prose-qti/components/choice/register.
Usage
Use the register side effect once, then call the insert command from your editor integration:
import '@citolab/prose-qti/components/choice/register';import { insertChoiceInteraction } from '@citolab/prose-qti/components/choice';
insertChoiceInteraction(view.state, view.dispatch, view);This package integrates with @qti-components/choice-interaction through the shared qti-components ecosystem.
What it does
- Inserts a
qti-choice-interactionnode with a prompt and placeholder choices. - Supports editing choice text directly in the editor.
- Supports single- and multiple-response authoring through interaction attributes.
- Works with conversion flows that turn selected text blocks into choice structures.
In the editor
- Edit the prompt by clicking into the prompt area and typing.
- Edit choice text by clicking into any choice and typing.
- Add a choice by pressing
Enterat the end of a choice — a new sibling choice is inserted automatically. - Mark correct responses by clicking the radio button or checkbox next to a choice.
Adding a choice from a custom affordance
If your host adds its own “add answer” UI rather than relying on Enter, build the new choice with createSimpleChoiceNode instead of constructing a qtiSimpleChoice node by hand. It is the same factory the Enter handler uses, so a choice added through your affordance is structurally identical to one added by pressing Enter.
import { createSimpleChoiceNode } from '@citolab/prose-qti/components/choice';
const choice = createSimpleChoiceNode(view.state.schema);if (choice) { const tr = view.state.tr.insert(insertPos, choice); view.dispatch(tr);}createSimpleChoiceNode returns null if the schema does not have qtiSimpleChoice/qtiSimpleChoiceParagraph nodes.
The default i18n messages also include choice.addOption, choice.removeOption and interaction.settings, so a host building such an affordance can label it without hardcoding English.
Built-in editor decorations (opt-in)
Rather than building the affordance above yourself, you can opt into the ready-made one: hovering a choice interaction reveals a boundary and a per-choice remove (×) button, a trailing + appends a new choice, and a settings pill floats above the interaction while the selection sits inside it. These are view-only decorations — they never enter the document, so exported XML is unaffected — and they are not included by default, since a read-only or player host must not get authoring chrome it never asked for.
Enable them by adding the decorator plugins and their stylesheet:
import { listInteractionDecoratorPluginFactories } from '@citolab/prose-qti/core/interactions/composer';import '@citolab/prose-qti/decorations.css';
const decoratorPlugins = listInteractionDecoratorPluginFactories().map(pluginFactory => pluginFactory());
const plugins = [...qtiPlugins, ...decoratorPlugins /* , … */];Use listSelectedInteractionDecoratorPluginFactories({ include: ['qti-choice-interaction'] }) instead if you only want decorations for a subset of interactions. If your integration uses ProseKit rather than raw ProseMirror plugins, defineQtiDecorationsExtension() from @citolab/prose-qti/integration/interactions/prosekit wraps the same factories as a ProseKit extension.
Choice is currently the only interaction with a decorator; the plumbing (decoratorPluginFactories on InteractionDescriptor) is generic, so more may follow.
The floating pill also exposes select, copy and delete — all self-contained, so they work with no wiring from your host — plus settings, which has no default behavior. Clicking it dispatches a qti:node-settings:open CustomEvent (bubbling, composed) on the editor view’s DOM, carrying a detail of { nodeTypeName, tagName, pos, attrs }; listen for it to open your own attribute panel for that node:
import { QTI_OPEN_NODE_SETTINGS_EVENT, type QtiOpenNodeSettingsDetail } from '@citolab/prose-qti/components/shared';
view.dom.addEventListener(QTI_OPEN_NODE_SETTINGS_EVENT, event => { const { nodeTypeName, tagName, pos, attrs } = (event as CustomEvent<QtiOpenNodeSettingsDetail>).detail; // open your properties UI for the node at `pos`});