Extending interactions
An interaction is a form-associated custom element with some wiring to the item around it.
Interaction is that element: it is formAssociated, attaches ElementInternals and exposes them
as internals, reports validity through them, resets on formResetCallback, and has name and
value. You add your own interaction by subclassing it and placing it in an item. The contract
below is pinned by the Extension API stories in Storybook (qti-assessment-item / Extension API),
which run exactly this kind of subclass inside a real item.
import { Interaction } from '@citolab/qti-components/qti-base';
class MyInteraction extends Interaction { #answer = '';
// Is the interaction in a valid state? The item asks, and publishes the verdict. validate(): boolean { return this.#answer !== ''; }
get response(): string | null { return this.#answer || null; }
// Called with a value that was restored or assigned from outside. Not called for your own answer. set response(value: string | string[] | null) { this.#answer = typeof value === 'string' ? value : ''; this.requestUpdate(); }
// Your own UI calls this when the candidate answers. #pick(answer: string) { this.#answer = answer; this.saveResponse(answer); }}
customElements.define('my-interaction', MyInteraction);<qti-assessment-item identifier="item-1" title="My item"> <qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="string"></qti-response-declaration> <qti-item-body> <my-interaction response-identifier="RESPONSE"></my-interaction> </qti-item-body></qti-assessment-item>What you implement
validate(): booleanget response()andset response(value)
response is the compatibility surface the item uses to talk to the interaction today, and the
project’s own rule calls it transitional. The base class does not set the form value for you: if
the interaction should take part in a <form>, call this.internals.setFormValue(…) yourself
when the answer changes.
Leaving out response is a compile error (Non-abstract class … does not implement inherited abstract member response).
What you call
saveResponse(value, state?)when the candidate answers. It reaches the item’s variables and firesqti-interaction-changed. Passstatefor opaque state that should survive a reload, such as a portable custom interaction’sgetState().
What the base class does for you
The form-element part (internals, setInteractionValidity for custom validity, reset) is described
above. The rest is the wiring to the item:
- Registers with the enclosing item. The item then asks your
validate(), and the verdict is published asitemContext.valid. - Hands your
responsesetter a value that was restored or assigned from outside, for exampleitem.variables = …orqtiTest.state = …. It does so only when the item’s value changed to something you did not publish yourself, so the setter never runs for the candidate’s own answer. - Follows the item’s
readonlyanddisabledthrough thereadonlyanddisabledproperties. An interaction you author with either set stays so inside an item that says nothing about it. - Provides
configContext: the delivery options the host set on the test or item.
Don’t
- Don’t redeclare
responseIdentifier. The base class binds it to theresponse-identifierattribute. A subclass that declares@property() responseIdentifieragain loses that binding: the attribute is read asresponseidentifierandresponse-identifier="…"leaves it unset. - Don’t call
saveResponsefrom theresponsesetter. The value came from outside and is already in the item. Only update your own state and re-render.
Components that read the session
The contexts a component can consume (testContext, computedContext, configContext, and the
item’s) are exported from the same entry point, @citolab/qti-components/qti-base. Consume them with
@consume from @lit/context, with subscribe: true.